feat: account settings page + dedicated help page
- Add /settings page with password change form and personas list - Add /help dedicated page (replaces help modal); renders HELP.md with collapsible sections, dark theme, back link to active persona - Add 👤 account button and convert ? button to link in header - Remove help modal HTML and ~55 lines of modal JS from main app - Register settings and help routers in main.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(messag
|
||||
from config import settings
|
||||
from auth_middleware import SessionAuthMiddleware
|
||||
from routers import chat, google_chat, nextcloud_talk, files, distill, auth, orchestrator
|
||||
from routers import ui, onboarding
|
||||
from routers import ui, onboarding, settings, help
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -42,6 +42,12 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
# Onboarding (invite tokens + persona creation — before ui.router)
|
||||
app.include_router(onboarding.router)
|
||||
|
||||
# Account settings
|
||||
app.include_router(settings.router)
|
||||
|
||||
# Help page
|
||||
app.include_router(help.router)
|
||||
|
||||
# UI router (login + /{user}/{persona} — must be last to avoid swallowing API paths)
|
||||
app.include_router(ui.router)
|
||||
|
||||
|
||||
50
cortex/routers/help.py
Normal file
50
cortex/routers/help.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Help page router.
|
||||
|
||||
Routes:
|
||||
GET /help → full-page help viewer (requires auth)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import jwt
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from auth_utils import COOKIE_NAME, decode_token
|
||||
from persona import list_user_personas
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_STATIC = Path(__file__).parent.parent / "static"
|
||||
|
||||
|
||||
def _get_session_user(request: Request) -> str | None:
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
return decode_token(token)
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/help", include_in_schema=False)
|
||||
async def help_page(request: Request):
|
||||
username = _get_session_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
|
||||
personas = list_user_personas(username)
|
||||
back_persona = personas[0] if personas else ""
|
||||
back_href = f"/{username}/{back_persona}" if back_persona else "/"
|
||||
|
||||
html = (_STATIC / "help.html").read_text()
|
||||
config_tag = (
|
||||
f'<script>window.HELP_CONFIG = '
|
||||
f'{{user: "{username}", persona: "{back_persona}", backHref: "{back_href}"}};</script>'
|
||||
)
|
||||
html = html.replace("</head>", f"{config_tag}\n</head>", 1)
|
||||
return HTMLResponse(html)
|
||||
84
cortex/routers/settings.py
Normal file
84
cortex/routers/settings.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Account settings router.
|
||||
|
||||
Routes:
|
||||
GET /settings → show account settings page (requires auth)
|
||||
POST /settings/password → change password
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import jwt
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from auth_utils import COOKIE_NAME, decode_token, check_credentials, set_password
|
||||
from persona import list_user_personas
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_STATIC = Path(__file__).parent.parent / "static"
|
||||
|
||||
|
||||
def _get_session_user(request: Request) -> str | None:
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
return decode_token(token)
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def _settings_page(username: str, personas: list[str], success: str = "", error: str = "") -> str:
|
||||
html = (_STATIC / "settings.html").read_text()
|
||||
html = html.replace("{{ username }}", username)
|
||||
persona_items = "\n".join(
|
||||
f'<li><a href="/{username}/{p}">{p}</a></li>' for p in personas
|
||||
)
|
||||
html = html.replace("{{ persona_items }}", persona_items or "<li><em>No personas yet.</em></li>")
|
||||
back_persona = personas[0] if personas else ""
|
||||
html = html.replace("{{ back_href }}", f"/{username}/{back_persona}" if back_persona else "/")
|
||||
if success:
|
||||
html = html.replace("<!-- SUCCESS -->", f'<p class="success">{success}</p>')
|
||||
if error:
|
||||
html = html.replace("<!-- ERROR -->", f'<p class="error">{error}</p>')
|
||||
return html
|
||||
|
||||
|
||||
@router.get("/settings", include_in_schema=False)
|
||||
async def settings_page(request: Request):
|
||||
username = _get_session_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
personas = list_user_personas(username)
|
||||
return HTMLResponse(_settings_page(username, personas))
|
||||
|
||||
|
||||
@router.post("/settings/password", include_in_schema=False)
|
||||
async def change_password(
|
||||
request: Request,
|
||||
current_password: str = Form(...),
|
||||
new_password: str = Form(...),
|
||||
confirm_password: str = Form(...),
|
||||
):
|
||||
username = _get_session_user(request)
|
||||
if not username:
|
||||
return RedirectResponse("/login", status_code=302)
|
||||
|
||||
personas = list_user_personas(username)
|
||||
|
||||
if not check_credentials(username, current_password):
|
||||
return HTMLResponse(_settings_page(username, personas, error="Current password is incorrect."))
|
||||
|
||||
if len(new_password) < 8:
|
||||
return HTMLResponse(_settings_page(username, personas, error="New password must be at least 8 characters."))
|
||||
|
||||
if new_password != confirm_password:
|
||||
return HTMLResponse(_settings_page(username, personas, error="New passwords do not match."))
|
||||
|
||||
set_password(username, new_password)
|
||||
logger.info("password changed: %s", username)
|
||||
return HTMLResponse(_settings_page(username, personas, success="Password updated successfully."))
|
||||
@@ -912,8 +912,6 @@
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (fileModal.classList.contains('open')) fileModal.classList.remove('open');
|
||||
if (document.getElementById('help-modal')?.classList.contains('open'))
|
||||
document.getElementById('help-modal').classList.remove('open');
|
||||
}
|
||||
// Ctrl+S to save when file modal is open
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's' && fileModal.classList.contains('open')) {
|
||||
@@ -1121,62 +1119,6 @@
|
||||
syncHeight();
|
||||
addMessage('system', 'Session started');
|
||||
|
||||
// ── Help modal ────────────────────────────────────────────────
|
||||
const helpBtn = document.getElementById('help-btn');
|
||||
const helpModal = document.getElementById('help-modal');
|
||||
const helpBody = document.getElementById('help-modal-body');
|
||||
const helpClose = document.getElementById('help-close-btn');
|
||||
|
||||
// Sections open by default — everything after "Notes" starts collapsed
|
||||
const HELP_OPEN_SECTIONS = new Set(['Header Controls', 'Chat', 'Sessions', 'Notes']);
|
||||
|
||||
function makeCollapsible(container) {
|
||||
const h2s = Array.from(container.querySelectorAll('h2'));
|
||||
for (const h2 of h2s) {
|
||||
const title = h2.textContent.trim();
|
||||
const details = document.createElement('details');
|
||||
if (HELP_OPEN_SECTIONS.has(title)) details.open = true;
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = title;
|
||||
details.appendChild(summary);
|
||||
|
||||
// Collect all following siblings until the next h2
|
||||
const siblings = [];
|
||||
let node = h2.nextSibling;
|
||||
while (node && node.nodeName !== 'H2') {
|
||||
siblings.push(node);
|
||||
node = node.nextSibling;
|
||||
}
|
||||
for (const sib of siblings) details.appendChild(sib);
|
||||
|
||||
h2.parentNode.replaceChild(details, h2);
|
||||
}
|
||||
}
|
||||
|
||||
async function openHelp() {
|
||||
helpBody.textContent = 'Loading…';
|
||||
helpModal.classList.add('open');
|
||||
try {
|
||||
const res = await fetch(`/files/HELP.md?${_fileParams}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
helpBody.innerHTML = marked.parse(data.content);
|
||||
helpBody.querySelectorAll('a').forEach(a => {
|
||||
a.target = '_blank'; a.rel = 'noopener noreferrer';
|
||||
});
|
||||
makeCollapsible(helpBody);
|
||||
} catch (err) {
|
||||
helpBody.textContent = `Failed to load help: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
helpBtn.addEventListener('click', openHelp);
|
||||
helpClose.addEventListener('click', () => helpModal.classList.remove('open'));
|
||||
helpModal.addEventListener('click', (e) => {
|
||||
if (e.target === helpModal) helpModal.classList.remove('open');
|
||||
});
|
||||
|
||||
// ── Auth token warning banner ─────────────────────────────
|
||||
const authBanner = document.getElementById('auth-banner');
|
||||
const authBannerMsg = document.getElementById('auth-banner-msg');
|
||||
|
||||
169
cortex/static/help.html
Normal file
169
cortex/static/help.html
Normal file
@@ -0,0 +1,169 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cortex — Help & Reference</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: #0f1117;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
color: #e2e8f0;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.back-link:hover { color: #a78bfa; }
|
||||
|
||||
header {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #2d3148;
|
||||
}
|
||||
header h1 { font-size: 1.5rem; font-weight: 700; color: #a78bfa; }
|
||||
header p { font-size: 0.85rem; color: #64748b; margin-top: 0.25rem; }
|
||||
|
||||
#help-body { line-height: 1.7; }
|
||||
|
||||
/* Collapsible sections */
|
||||
details {
|
||||
margin-bottom: 0.75rem;
|
||||
background: #1a1d27;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
summary {
|
||||
padding: 0.85rem 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: #cbd5e1;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
summary::before {
|
||||
content: '▶';
|
||||
font-size: 0.65rem;
|
||||
color: #64748b;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
details[open] summary::before { transform: rotate(90deg); }
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
|
||||
details > *:not(summary) {
|
||||
padding: 0 1rem 1rem;
|
||||
}
|
||||
|
||||
#help-body p { margin: 0.5rem 0; font-size: 0.9rem; color: #cbd5e1; }
|
||||
#help-body ul { margin: 0.5rem 0 0.5rem 1.25rem; }
|
||||
#help-body li { font-size: 0.9rem; color: #cbd5e1; margin-bottom: 0.25rem; }
|
||||
#help-body strong { color: #e2e8f0; }
|
||||
#help-body code {
|
||||
background: #0f1117;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.4em;
|
||||
font-size: 0.85em;
|
||||
color: #a78bfa;
|
||||
}
|
||||
#help-body a { color: #a78bfa; }
|
||||
|
||||
#help-body h3 {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0.75rem 0 0.25rem;
|
||||
}
|
||||
|
||||
#loading { color: #64748b; font-size: 0.9rem; padding: 1rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<a id="back-link" href="/" class="back-link">← Back to Cortex</a>
|
||||
|
||||
<header>
|
||||
<h1>Help & Reference</h1>
|
||||
<p id="persona-label"></p>
|
||||
</header>
|
||||
|
||||
<div id="help-body"><p id="loading">Loading…</p></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const cfg = window.HELP_CONFIG || {};
|
||||
const user = cfg.user || 'scott';
|
||||
const persona = cfg.persona || 'inara';
|
||||
const params = `user=${encodeURIComponent(user)}&persona=${encodeURIComponent(persona)}`;
|
||||
|
||||
// Wire up back link and persona label
|
||||
document.getElementById('back-link').href = cfg.backHref || '/';
|
||||
if (persona) {
|
||||
document.getElementById('persona-label').textContent =
|
||||
`${persona.charAt(0).toUpperCase() + persona.slice(1)} · ${user}`;
|
||||
}
|
||||
|
||||
const OPEN_SECTIONS = new Set(['Header Controls', 'Chat', 'Sessions', 'Notes']);
|
||||
|
||||
function makeCollapsible(container) {
|
||||
const h2s = Array.from(container.querySelectorAll('h2'));
|
||||
for (const h2 of h2s) {
|
||||
const title = h2.textContent.trim();
|
||||
const details = document.createElement('details');
|
||||
if (OPEN_SECTIONS.has(title)) details.open = true;
|
||||
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = title;
|
||||
details.appendChild(summary);
|
||||
|
||||
const siblings = [];
|
||||
let node = h2.nextSibling;
|
||||
while (node && node.nodeName !== 'H2') {
|
||||
siblings.push(node);
|
||||
node = node.nextSibling;
|
||||
}
|
||||
for (const sib of siblings) details.appendChild(sib);
|
||||
h2.parentNode.replaceChild(details, h2);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHelp() {
|
||||
try {
|
||||
const res = await fetch(`/files/HELP.md?${params}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const body = document.getElementById('help-body');
|
||||
body.innerHTML = marked.parse(data.content);
|
||||
body.querySelectorAll('a').forEach(a => {
|
||||
a.target = '_blank'; a.rel = 'noopener noreferrer';
|
||||
});
|
||||
makeCollapsible(body);
|
||||
} catch (err) {
|
||||
document.getElementById('help-body').textContent = `Failed to load help: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
loadHelp();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -30,7 +30,8 @@
|
||||
<button id="sessions-btn" class="hdr-btn">Sessions</button>
|
||||
<button id="files-btn" class="hdr-btn">Files</button>
|
||||
<button id="ctx-open-btn" class="hdr-btn" title="Settings">⚙<span class="tier-badge">2</span></button>
|
||||
<button id="help-btn" class="hdr-btn" title="Help & reference">?</button>
|
||||
<a href="/help" class="hdr-btn" title="Help & reference" style="text-decoration:none">?</a>
|
||||
<a id="account-btn" href="/settings" class="hdr-btn" title="Account settings" style="text-decoration:none">👤</a>
|
||||
<form method="POST" action="/logout" style="margin:0">
|
||||
<button type="submit" class="hdr-btn" title="Sign out" id="logout-btn">⏏</button>
|
||||
</form>
|
||||
@@ -102,17 +103,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help modal -->
|
||||
<div id="help-modal">
|
||||
<div id="help-modal-inner">
|
||||
<div id="help-modal-header">
|
||||
<h2>Cortex — Help & Reference</h2>
|
||||
<button class="fm-btn" id="help-close-btn">✕</button>
|
||||
</div>
|
||||
<div id="help-modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auth warning banner — shown when Claude CLI token is near expiry -->
|
||||
<div id="auth-banner">
|
||||
<div id="auth-banner-text">
|
||||
|
||||
205
cortex/static/settings.html
Normal file
205
cortex/static/settings.html
Normal file
@@ -0,0 +1,205 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cortex — Account Settings</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0f1117;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
color: #e2e8f0;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #1a1d27;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 12px;
|
||||
padding: 2.5rem 2rem;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.back-link:hover { color: #a78bfa; }
|
||||
|
||||
.logo {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
.logo h1 { font-size: 1.4rem; font-weight: 700; color: #a78bfa; }
|
||||
.logo p { font-size: 0.8rem; color: #64748b; margin-top: 0.2rem; }
|
||||
|
||||
h2 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid #2d3148;
|
||||
}
|
||||
|
||||
.section { margin-bottom: 2rem; }
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: #0f1117;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 6px;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input:focus { border-color: #7c3aed; }
|
||||
input[readonly] { color: #64748b; cursor: default; }
|
||||
|
||||
.field { margin-bottom: 1rem; }
|
||||
|
||||
button[type="submit"] {
|
||||
width: 100%;
|
||||
padding: 0.7rem;
|
||||
margin-top: 0.25rem;
|
||||
background: #7c3aed;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button[type="submit"]:hover { background: #6d28d9; }
|
||||
|
||||
.error {
|
||||
color: #f87171;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #4ade80;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.persona-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.persona-list li a {
|
||||
display: inline-block;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: #0f1117;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 20px;
|
||||
color: #a78bfa;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.persona-list li a:hover { border-color: #7c3aed; }
|
||||
.persona-list li em { color: #475569; font-size: 0.85rem; }
|
||||
|
||||
.add-persona {
|
||||
display: inline-block;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
}
|
||||
.add-persona:hover { color: #a78bfa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<a href="{{ back_href }}" class="back-link">← Back to Cortex</a>
|
||||
|
||||
<div class="logo">
|
||||
<h1>Account Settings</h1>
|
||||
<p>Manage your account and personas.</p>
|
||||
</div>
|
||||
|
||||
<!-- SUCCESS -->
|
||||
<!-- ERROR -->
|
||||
|
||||
<!-- Account info -->
|
||||
<div class="section">
|
||||
<h2>Account</h2>
|
||||
<div class="field">
|
||||
<label>Username</label>
|
||||
<input type="text" value="{{ username }}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change password -->
|
||||
<div class="section">
|
||||
<h2>Change Password</h2>
|
||||
<form method="POST" action="/settings/password">
|
||||
<div class="field">
|
||||
<label for="current_password">Current password</label>
|
||||
<input type="password" id="current_password" name="current_password"
|
||||
autocomplete="current-password" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new_password">New password</label>
|
||||
<input type="password" id="new_password" name="new_password"
|
||||
autocomplete="new-password" required minlength="8">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="confirm_password">Confirm new password</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password"
|
||||
autocomplete="new-password" required>
|
||||
</div>
|
||||
<button type="submit">Update password</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Personas -->
|
||||
<div class="section">
|
||||
<h2>Personas</h2>
|
||||
<ul class="persona-list">
|
||||
{{ persona_items }}
|
||||
</ul>
|
||||
<a href="/setup/persona" class="add-persona">+ Add new persona</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelector('form').addEventListener('submit', e => {
|
||||
const np = document.getElementById('new_password').value;
|
||||
const cfm = document.getElementById('confirm_password').value;
|
||||
if (np !== cfm) {
|
||||
e.preventDefault();
|
||||
alert('New passwords do not match.');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user