First incremental pass at issue #86, focused on the universal entry points and primary navigation. All changes verified in-browser with the axe-core engine (0 violations on the surfaces below) plus manual keyboard testing, on both desktop (1280px) and mobile (390px). Login / first-run setup (static/login.html) - Add a real <h1>, wrap content in <main> + <footer> landmarks. - Mark the decorative boat SVG aria-hidden. - Errors now use role="alert" so screen readers announce them. - "Remember me" checkbox is keyboard-focusable (was display:none) with an accessible name and a focus ring; dynamic 2FA field gets a linked label. - Darken the brand-red submit button so white text clears WCAG AA 4.5:1 (was ~3.2:1); add visible :focus-visible rings. App shell (static/index.html, static/style.css) - Remove invalid role="region" from the <main> chat container (it was overriding the implicit main landmark). - Add a persistent, visually-hidden <h1> inside <main> so the page always exposes one logical level-1 heading — works even on mobile where the sidebar (with the visible brand) is hidden off-canvas. - Add a reusable .a11y-visually-hidden utility. - Raise chat-title, model-picker, settings-helper and notes text contrast above 4.5:1 (were 2.8-3.9:1). Keyboard nav + dialogs (static/js/a11y.js - new) - Make the click-only <div> sidebar navigation (New Chat, Search, Brain, Calendar, Compare, Cookbook, Deep Research, Gallery, Library, Notes, Tasks, Theme, account) focusable and Enter/Space-activatable, announced as buttons (skipping role=button where a nested control would create a nested-interactive violation). Visible focus ring reused from existing .list-item:focus-visible. - Upgrade modals (.modal-content and the docked .notes-pane) to labelled role="dialog" + aria-modal, and normalise their title to heading level 2 so heading order stays valid. A MutationObserver covers runtime-rendered rows and modals. Decorative background canvases (static/js/theme.js) - Mark all 7 bg-effect canvases aria-hidden. Notes & Tasks (static/js/notes.js, static/js/tasks.js) - Label the icon-only Note/To-do toggle pills (fixes a critical button-name issue) and track aria-pressed state. - Improve Notes header-button + empty-state contrast. - Give the Tasks sort <select> an accessible name (fixes a critical select-name issue). Remaining data-dense tool modals (Tasks cards, Calendar, Gallery, Email, Cookbook, Compare, Deep Research) still have muted-text contrast to polish and are the next incremental step, per the issue's own guidance.
575 lines
26 KiB
HTML
575 lines
26 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-visual">
|
|
<title>Odysseus — Login</title>
|
|
<script nonce="{{CSP_NONCE}}">
|
|
(function(){
|
|
// Per-theme bg-effect defaults — mirrors THEME_DEFAULT_* maps in
|
|
// static/js/theme.js so login picks the same default pattern as the
|
|
// main app for users who never explicitly chose one.
|
|
var THEME_DEFAULT_PATTERN = {
|
|
dark:'none', light:'dots', midnight:'rain', paper:'dots',
|
|
cyberpunk:'synapse', retrowave:'embers', forest:'petals',
|
|
ocean:'constellations', terminal:'perlin-flow', organs:'rain',
|
|
ume:'petals', cute:'sparkles'
|
|
};
|
|
var THEME_DEFAULT_EFFECT_COLOR = {
|
|
midnight:'#ffffff', organs:'#451616', cute:'#ff8cb8', ume:'#f5a0c0'
|
|
};
|
|
var THEME_DEFAULT_INTENSITY = { midnight:0.5, terminal:0.8, organs:0.65 };
|
|
try {
|
|
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
|
|
var s = document.documentElement.style;
|
|
if (t && t.colors) {
|
|
var c = t.colors;
|
|
// Base palette
|
|
s.setProperty('--bg', c.bg);
|
|
s.setProperty('--fg', c.fg);
|
|
s.setProperty('--panel', c.panel);
|
|
s.setProperty('--border', c.border);
|
|
if (c.red) s.setProperty('--red', c.red);
|
|
if (c.green) s.setProperty('--green', c.green);
|
|
// Advanced overrides (sidebar logo color, input bg, send-button bg, etc.)
|
|
// — mirrors ADV_KEYS in static/js/theme.js so the login page picks up
|
|
// every customization the user has on their theme instead of falling
|
|
// back to defaults that didn't match the main app.
|
|
var a = c.advanced || {};
|
|
var ADV = {
|
|
userBubbleBg: '--user-bubble-bg',
|
|
aiBubbleBg: '--ai-bubble-bg',
|
|
bubbleBorder: '--bubble-border',
|
|
sidebarBg: '--sidebar-bg',
|
|
brandColor: '--brand-color',
|
|
hamburgerColor: '--hamburger-color',
|
|
inputBg: '--input-bg',
|
|
inputBorder: '--input-border',
|
|
sendBtnBg: '--send-btn-bg',
|
|
sendBtnHover: '--send-btn-hover',
|
|
codeBg: '--code-bg',
|
|
codeFg: '--code-fg',
|
|
toggleActive: '--toggle-active',
|
|
};
|
|
for (var k in ADV) { if (a[k]) s.setProperty(ADV[k], a[k]); }
|
|
}
|
|
// Background effect — pick ONE at random from the main app's 8
|
|
// patterns each load (matches the old login behavior, but uses the
|
|
// same effect implementations the main theme system exposes so the
|
|
// login feels consistent with the rest of the app). Per-theme
|
|
// bgEffectColor/Intensity defaults still apply so the random
|
|
// pattern picks up theme-appropriate tinting.
|
|
var name = (t && t.name) || '';
|
|
// perlin-flow excluded — too visually intense for a login screen.
|
|
var PATTERNS = ['dots','synapse','rain','constellations','petals','sparkles','embers'];
|
|
var pattern = PATTERNS[Math.floor(Math.random() * PATTERNS.length)];
|
|
var effColor = (t && t.bgEffectColor) || THEME_DEFAULT_EFFECT_COLOR[name] || '';
|
|
var effInt = (t && t.bgEffectIntensity !== undefined)
|
|
? t.bgEffectIntensity
|
|
: (THEME_DEFAULT_INTENSITY[name] !== undefined ? THEME_DEFAULT_INTENSITY[name] : 1);
|
|
var effSize = (t && t.bgEffectSize !== undefined) ? t.bgEffectSize : 1;
|
|
if (effColor) s.setProperty('--bg-effect-color', effColor);
|
|
s.setProperty('--bg-effect-intensity', String(effInt));
|
|
s.setProperty('--bg-effect-size', String(effSize));
|
|
// Stash so the deferred module knows which canvas effect to start.
|
|
window.__loginBgPattern = pattern;
|
|
// Apply the body class as soon as <body> exists so static-gradient
|
|
// patterns (dots, synapse) paint immediately on first frame.
|
|
var apply = function() { document.body.classList.add('bg-pattern-' + pattern); };
|
|
if (document.body) apply();
|
|
else document.addEventListener('DOMContentLoaded', apply, { once: true });
|
|
} catch(e){}
|
|
})();
|
|
</script>
|
|
<style>
|
|
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
|
|
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
|
|
/* Mirror the main app's :root defaults (static/style.css ~line 18) so an
|
|
uncustomized theme — or a fresh browser with no `odysseus-theme` in
|
|
localStorage — renders the login page in the same palette as the rest
|
|
of the app instead of a different greys+offwhite scheme. */
|
|
:root {
|
|
--bg: #282c34; --fg: #9cdef2; --panel: #111; --border: #355a66;
|
|
--red: #e06c75; --green: #50fa7b;
|
|
--bg-effect-intensity: 1;
|
|
}
|
|
/* Background patterns — mirrors static/style.css "Background Patterns"
|
|
block so the theme's chosen bgPattern renders on login too. The
|
|
canvas-based effects (rain, constellations, perlin-flow, petals,
|
|
sparkles, embers) are spawned by the deferred theme.js import below;
|
|
the static gradient ones (dots, synapse) need these CSS rules. */
|
|
#synapse-canvas, #rain-canvas, #constellations-canvas,
|
|
#perlin-flow-canvas, #petals-canvas, #sparkles-canvas,
|
|
#embers-canvas { opacity: var(--bg-effect-intensity, 1); }
|
|
body.bg-pattern-dots {
|
|
background-image: radial-gradient(color-mix(in srgb, var(--bg-effect-color, var(--fg)) calc(5% * var(--bg-effect-intensity, 1)), transparent) 1px, transparent 1px);
|
|
background-size: 20px 20px; background-attachment: fixed;
|
|
}
|
|
body.bg-pattern-synapse {
|
|
background-image: linear-gradient(color-mix(in srgb, var(--bg-effect-color, var(--fg)) calc(3.5% * var(--bg-effect-intensity, 1)), transparent) 1px, transparent 1px),
|
|
linear-gradient(90deg, color-mix(in srgb, var(--bg-effect-color, var(--fg)) calc(3.5% * var(--bg-effect-intensity, 1)), transparent) 1px, transparent 1px);
|
|
background-size: 24px 24px; background-attachment: fixed;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
font-family: 'Fira Code', monospace;
|
|
background: var(--bg); color: var(--fg);
|
|
display: flex; justify-content: center; align-items: flex-start;
|
|
min-height: 100vh; min-height: 100dvh;
|
|
padding-top: 15vh;
|
|
transition: padding-top 0.2s ease;
|
|
}
|
|
.card {
|
|
background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
|
|
padding: 2rem; width: 360px; max-width: 90vw;
|
|
/* Sit above the bg-effect canvases (which spawn at z-index:0) so the
|
|
falling rain / drifting petals / etc. don't streak across the
|
|
login form. */
|
|
position: relative; z-index: 1;
|
|
}
|
|
.logo {
|
|
text-align: center; margin-bottom: 1.5rem;
|
|
}
|
|
.logo span {
|
|
font-family: 'Fira Code', monospace;
|
|
font-size: 2rem; font-weight: 600;
|
|
letter-spacing: 0.04em;
|
|
/* Same gradient as the main app's welcome-screen "Odysseus" title
|
|
(static/style.css #welcome-screen .welcome-name) so the brand
|
|
wordmark is consistent across login + first-launch screens. */
|
|
background: linear-gradient(135deg, var(--brand-color, var(--red)), color-mix(in srgb, var(--brand-color, var(--red)) 60%, var(--fg)));
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
background-clip: text;
|
|
}
|
|
.setup-note { color: color-mix(in srgb, var(--fg) 60%, transparent); font-size: 0.8rem; margin-bottom: 1rem; text-align: center; }
|
|
label { display: block; font-size: 0.85rem; margin-bottom: 0.3rem; color: color-mix(in srgb, var(--fg) 65%, transparent); }
|
|
input:not(.remember-check) {
|
|
width: 100%; padding: 0.6rem 0.8rem; margin-bottom: 1rem;
|
|
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
|
color: var(--fg); font-size: 0.95rem; font-family: 'Fira Code', monospace;
|
|
}
|
|
input:focus { outline: none; border-color: var(--red); }
|
|
/* Clear, visible focus ring for keyboard users on every focusable control. */
|
|
input:focus-visible, a:focus-visible, button:focus-visible {
|
|
outline: 2px solid var(--red);
|
|
outline-offset: 2px;
|
|
}
|
|
button {
|
|
width: 100%;
|
|
/* Asymmetric vertical padding nudges the label 1px down while keeping
|
|
the button's total height the same as 0.7rem all-around. */
|
|
padding: calc(0.7rem + 1px) 0.7rem calc(0.7rem - 1px);
|
|
border: none; border-radius: 6px;
|
|
/* Darken the brand red slightly so #fff label text clears the WCAG AA
|
|
4.5:1 contrast threshold (plain --red #e06c75 only reaches ~3.2:1). */
|
|
background: color-mix(in srgb, var(--red) 78%, #000); color: #fff; font-size: 1rem; cursor: pointer;
|
|
font-weight: 600; font-family: 'Fira Code', monospace;
|
|
}
|
|
button:hover { background: color-mix(in srgb, var(--red) 66%, black); }
|
|
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
.error { color: #e55; font-size: 0.85rem; margin-bottom: 0.75rem; display: none; }
|
|
.toggle { text-align: center; margin-top: calc(1rem + 4px); font-size: 0.85rem; color: color-mix(in srgb, var(--fg) 50%, transparent); }
|
|
.toggle a { color: var(--red); cursor: pointer; text-decoration: none; }
|
|
.toggle a:hover { text-decoration: underline; }
|
|
.pw-wrapper { position: relative; margin-bottom: 1rem; }
|
|
.pw-wrapper input:not(.remember-check) { padding-right: 2.5rem; margin-bottom: 0; }
|
|
.pw-toggle {
|
|
position: absolute; right: 8px; top: 50%;
|
|
/* +1px on the centering offset nudges the eye icon down 1px so it
|
|
lines up visually with the input text baseline. */
|
|
transform: translateY(calc(-50% + 1px));
|
|
background: none; border: none; padding: 4px; cursor: pointer;
|
|
color: color-mix(in srgb, var(--fg) 40%, transparent); width: auto;
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
.pw-toggle:hover { color: var(--fg); background: none; }
|
|
.pw-toggle svg { display: block; }
|
|
.remember-toggle {
|
|
position: absolute !important; right: 13px; top: 50%; transform: translateY(-50%);
|
|
background: none; border: none; padding: 2px; cursor: pointer;
|
|
width: 14px !important; height: 14px !important; display: flex !important;
|
|
align-items: center; justify-content: center;
|
|
font-size: 0; margin: 0; color: transparent;
|
|
}
|
|
/* Visually hide the native checkbox but keep it in the accessibility tree
|
|
and keyboard-focusable (display:none would drop it from tab order). It
|
|
overlays the dot so a click/tap still toggles it. */
|
|
.remember-toggle .remember-check {
|
|
position: absolute; top: 0; left: 0;
|
|
width: 100%; height: 100%; margin: 0;
|
|
opacity: 0; cursor: pointer;
|
|
}
|
|
.remember-toggle .remember-check:focus-visible + .remember-dot {
|
|
outline: 2px solid var(--red); outline-offset: 2px;
|
|
}
|
|
.remember-toggle .remember-dot {
|
|
display: block; width: 10px; height: 10px; min-width: 10px; min-height: 10px;
|
|
border-radius: 50%;
|
|
border: 2px solid color-mix(in srgb, var(--fg) 25%, transparent);
|
|
background: transparent;
|
|
transition: all 0.15s;
|
|
}
|
|
.remember-toggle .remember-check:checked + .remember-dot {
|
|
border-color: transparent;
|
|
background: color-mix(in srgb, var(--fg) 40%, transparent);
|
|
}
|
|
.remember-toggle:hover .remember-dot {
|
|
border-color: color-mix(in srgb, var(--fg) 40%, transparent);
|
|
}
|
|
.logo-boat { width: 1.6rem; height: 1.6rem; margin-right: 0.4rem; vertical-align: -0.15em; color: var(--red); }
|
|
.version-label {
|
|
position: fixed; bottom: 12px; right: 16px;
|
|
font-size: 0.7rem; opacity: 0.25;
|
|
font-family: 'Fira Code', monospace;
|
|
pointer-events: none; user-select: none;
|
|
}
|
|
/* Whirlpool-style spinner shown in the submit button while login /
|
|
account-creation work is in flight — replaces the previous "Loading…"
|
|
text. Two-color ring (muted base + accent arc) rotates continuously. */
|
|
.login-spinner {
|
|
display: inline-block;
|
|
width: 16px; height: 16px;
|
|
border: 2px solid color-mix(in srgb, var(--fg, #fff) 22%, transparent);
|
|
border-top-color: var(--accent, var(--red, #e8a830));
|
|
border-radius: 50%;
|
|
animation: login-spin 0.7s linear infinite;
|
|
vertical-align: -3px;
|
|
}
|
|
@keyframes login-spin { to { transform: rotate(360deg); } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="card">
|
|
<h1 class="logo">
|
|
<svg class="logo-boat" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity="0.6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg><span>Odysseus</span>
|
|
</h1>
|
|
<p class="setup-note" id="setupNote" style="display:none"></p>
|
|
|
|
<div class="error" id="error" role="alert" aria-live="assertive"></div>
|
|
|
|
<form id="authForm" autocomplete="on">
|
|
<label for="username">Username</label>
|
|
<div class="pw-wrapper">
|
|
<input id="username" name="username" type="text" required autofocus autocomplete="username">
|
|
<label class="remember-toggle" id="rememberToggle" title="Remember me">
|
|
<input type="checkbox" class="remember-check" id="remember" checked aria-label="Remember me">
|
|
<span class="remember-dot" aria-hidden="true"></span>
|
|
</label>
|
|
</div>
|
|
|
|
<label for="password">Password</label>
|
|
<div class="pw-wrapper">
|
|
<input id="password" name="password" type="password" required autocomplete="current-password">
|
|
<button type="button" class="pw-toggle" id="pwToggle" tabindex="-1" aria-label="Show password">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div id="confirmGroup" style="display:none">
|
|
<label for="confirmPassword">Confirm Password</label>
|
|
<div class="pw-wrapper">
|
|
<input id="confirmPassword" name="confirmPassword" type="password" autocomplete="new-password">
|
|
<button type="button" class="pw-toggle" id="pwToggleConfirm" tabindex="-1" aria-label="Show password">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" id="submitBtn">Sign In</button>
|
|
</form>
|
|
|
|
<div class="toggle" id="toggleArea" style="display:none">
|
|
<span id="toggleText">Don't have an account? </span>
|
|
<a id="toggleLink" href="#">Sign up</a>
|
|
</div>
|
|
</main>
|
|
|
|
<footer class="version-label" id="version-label"></footer>
|
|
|
|
<script nonce="{{CSP_NONCE}}">
|
|
(async () => {
|
|
// Load version
|
|
try {
|
|
const vr = await fetch('/api/version');
|
|
if (vr.ok) {
|
|
const vd = await vr.json();
|
|
document.getElementById('version-label').textContent = 'v' + vd.version;
|
|
}
|
|
} catch(e) {}
|
|
|
|
// Prefill last username
|
|
const usernameInput = document.getElementById('username');
|
|
const savedUser = localStorage.getItem('odysseus-last-user');
|
|
if (savedUser && usernameInput) {
|
|
usernameInput.value = savedUser;
|
|
document.getElementById('password').focus();
|
|
}
|
|
|
|
const form = document.getElementById('authForm');
|
|
const errEl = document.getElementById('error');
|
|
const setupNote = document.getElementById('setupNote');
|
|
const confirmGroup = document.getElementById('confirmGroup');
|
|
const submitBtn = document.getElementById('submitBtn');
|
|
const toggleArea = document.getElementById('toggleArea');
|
|
const toggleLink = document.getElementById('toggleLink');
|
|
const toggleText = document.getElementById('toggleText');
|
|
|
|
let mode = 'login'; // 'login' | 'signup' | 'setup'
|
|
let signupAllowed = false;
|
|
|
|
const rememberToggle = document.getElementById('rememberToggle');
|
|
|
|
function setMode(m) {
|
|
mode = m;
|
|
errEl.style.display = 'none';
|
|
if (m === 'setup') {
|
|
setupNote.textContent = 'First-time setup — create your admin account';
|
|
setupNote.style.display = 'block';
|
|
confirmGroup.style.display = 'block';
|
|
submitBtn.textContent = 'Create Admin Account';
|
|
toggleArea.style.display = 'none';
|
|
rememberToggle.style.display = 'none';
|
|
} else if (m === 'signup') {
|
|
setupNote.style.display = 'none';
|
|
confirmGroup.style.display = 'block';
|
|
submitBtn.innerHTML = '<span style="position:relative;top:1px;">Create Account</span>';
|
|
toggleArea.style.display = 'block';
|
|
toggleText.textContent = 'Already have an account? ';
|
|
toggleLink.textContent = 'Sign in';
|
|
rememberToggle.style.display = 'none';
|
|
} else {
|
|
setupNote.style.display = 'none';
|
|
confirmGroup.style.display = 'none';
|
|
submitBtn.textContent = 'Sign In';
|
|
toggleArea.style.display = signupAllowed ? 'block' : 'none';
|
|
toggleText.textContent = "Don't have an account? ";
|
|
toggleLink.textContent = 'Sign up';
|
|
rememberToggle.style.display = '';
|
|
}
|
|
}
|
|
|
|
// Check auth status
|
|
try {
|
|
const res = await fetch('/api/auth/status', { credentials: 'same-origin' });
|
|
const data = await res.json();
|
|
if (data.authenticated) {
|
|
window.location.replace('/');
|
|
return;
|
|
}
|
|
signupAllowed = !!data.signup_enabled;
|
|
if (!data.configured) {
|
|
setMode('setup');
|
|
} else {
|
|
setMode('login');
|
|
}
|
|
} catch (e) {
|
|
setMode('login');
|
|
}
|
|
|
|
toggleLink.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
setMode(mode === 'login' ? 'signup' : 'login');
|
|
});
|
|
|
|
form.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
errEl.style.display = 'none';
|
|
submitBtn.disabled = true;
|
|
|
|
const username = document.getElementById('username').value.trim();
|
|
const password = document.getElementById('password').value;
|
|
|
|
// If in TOTP mode, handle the code submission directly
|
|
if (form._totpMode) {
|
|
const totpInput = document.getElementById('totp-input');
|
|
const code = totpInput ? totpInput.value.trim() : '';
|
|
if (!code) { totpInput.focus(); submitBtn.disabled = false; return; }
|
|
const remember = document.getElementById('remember').checked;
|
|
try {
|
|
const res = await fetch('/api/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ username, password, remember, totp_code: code })
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.detail || 'Invalid code');
|
|
if (!data.ok) throw new Error('Login failed');
|
|
form._totpMode = false;
|
|
finishLogin();
|
|
} catch (err) {
|
|
errEl.textContent = err.message;
|
|
errEl.style.display = 'block';
|
|
submitBtn.disabled = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Validate confirm password for setup/signup
|
|
if (mode === 'setup' || mode === 'signup') {
|
|
const confirm = document.getElementById('confirmPassword').value;
|
|
if (password !== confirm) {
|
|
errEl.textContent = 'Passwords do not match';
|
|
errEl.style.display = 'block';
|
|
submitBtn.disabled = false;
|
|
return;
|
|
}
|
|
if (password.length < 8) {
|
|
errEl.textContent = 'Password must be at least 8 characters';
|
|
errEl.style.display = 'block';
|
|
submitBtn.disabled = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Setup or signup first
|
|
if (mode === 'setup' || mode === 'signup') {
|
|
const endpoint = mode === 'setup' ? '/api/auth/setup' : '/api/auth/signup';
|
|
try {
|
|
const res = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ username, password })
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.detail || 'Account creation failed');
|
|
} catch (err) {
|
|
errEl.textContent = err.message;
|
|
errEl.style.display = 'block';
|
|
submitBtn.disabled = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Login (auto-login after setup/signup too)
|
|
const remember = document.getElementById('remember').checked;
|
|
function finishLogin() {
|
|
const _rem = document.getElementById('remember').checked;
|
|
if (_rem) localStorage.setItem('odysseus-last-user', username);
|
|
else localStorage.removeItem('odysseus-last-user');
|
|
sessionStorage.removeItem('ody-session-active');
|
|
submitBtn.innerHTML = '<span class="login-spinner" aria-hidden="true"></span>';
|
|
submitBtn.disabled = true;
|
|
Promise.all([
|
|
fetch('/api/sessions', { credentials: 'same-origin' }).then(r => r.json()),
|
|
fetch('/api/auth/features', { credentials: 'same-origin' }).then(r => r.json()),
|
|
fetch('/api/auth/settings', { credentials: 'same-origin' }).then(r => r.json()),
|
|
]).then(([sess, feat, sett]) => {
|
|
sessionStorage.setItem('ody-prefetch-sessions', JSON.stringify(sess));
|
|
sessionStorage.setItem('ody-prefetch-features', JSON.stringify(feat));
|
|
sessionStorage.setItem('ody-prefetch-settings', JSON.stringify(sett));
|
|
}).catch(() => {}).finally(() => { window.location.replace('/'); });
|
|
}
|
|
async function doLogin(totpCode) {
|
|
const loginBody = { username, password, remember };
|
|
if (totpCode) loginBody.totp_code = totpCode;
|
|
const res = await fetch('/api/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify(loginBody)
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.detail || 'Login failed');
|
|
return data;
|
|
}
|
|
try {
|
|
let data = await doLogin();
|
|
// 2FA required — show TOTP input
|
|
if (data.requires_totp) {
|
|
submitBtn.disabled = false;
|
|
// Only add TOTP input once
|
|
if (document.getElementById('totp-input')) return;
|
|
form._totpMode = true;
|
|
const totpWrap = document.createElement('div');
|
|
totpWrap.style.cssText = 'margin-top:12px;';
|
|
totpWrap.innerHTML = '<label for="totp-input" style="font-size:0.85em;opacity:0.7;display:block;margin-bottom:4px;">2FA Code</label><input type="text" id="totp-input" placeholder="Enter 6-digit code" aria-label="Two-factor authentication code" autocomplete="one-time-code" inputmode="numeric" maxlength="8" style="width:100%;padding:10px 12px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:8px;font-size:14px;box-sizing:border-box;text-align:center;letter-spacing:4px;">';
|
|
const formEl = submitBtn.parentElement;
|
|
formEl.insertBefore(totpWrap, submitBtn);
|
|
const totpInput = document.getElementById('totp-input');
|
|
totpInput.focus();
|
|
submitBtn.textContent = 'Verify';
|
|
return;
|
|
}
|
|
finishLogin();
|
|
} catch (err) {
|
|
errEl.textContent = err.message;
|
|
errEl.style.display = 'block';
|
|
submitBtn.disabled = false;
|
|
return;
|
|
}
|
|
});
|
|
|
|
// Password show/hide toggles
|
|
const eyeOpen = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
|
const eyeClosed = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>';
|
|
|
|
function wireToggle(btnId, inputId) {
|
|
const btn = document.getElementById(btnId);
|
|
const inp = document.getElementById(inputId);
|
|
if (!btn || !inp) return;
|
|
btn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
const show = inp.type === 'password';
|
|
inp.type = show ? 'text' : 'password';
|
|
btn.innerHTML = show ? eyeOpen : eyeClosed;
|
|
btn.setAttribute('aria-label', show ? 'Hide password' : 'Show password');
|
|
inp.focus();
|
|
});
|
|
}
|
|
wireToggle('pwToggle', 'password');
|
|
wireToggle('pwToggleConfirm', 'confirmPassword');
|
|
})();
|
|
|
|
// Prevent login button and eye toggles from stealing focus on mobile (keeps keyboard open)
|
|
document.querySelectorAll('#submitBtn, .pw-toggle').forEach(btn => {
|
|
btn.addEventListener('touchstart', (e) => { e.preventDefault(); }, { passive: false });
|
|
btn.addEventListener('touchend', (e) => {
|
|
e.preventDefault();
|
|
btn.click();
|
|
});
|
|
});
|
|
|
|
// Mobile keyboard: shift card up when virtual keyboard opens
|
|
if (window.visualViewport) {
|
|
const card = document.querySelector('.card');
|
|
window.visualViewport.addEventListener('resize', () => {
|
|
const vvh = window.visualViewport.height;
|
|
const wh = window.innerHeight;
|
|
if (vvh < wh * 0.8) {
|
|
document.body.style.paddingTop = '5vh';
|
|
// Keep the active input visible without jumping the whole card to the top
|
|
if (document.activeElement && document.activeElement.tagName === 'INPUT') {
|
|
document.activeElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
} else {
|
|
document.body.style.paddingTop = '';
|
|
}
|
|
});
|
|
}
|
|
</script>
|
|
<script type="module" nonce="{{CSP_NONCE}}">
|
|
// Drive the login page's bg effect off the user's saved theme. The
|
|
// sync bootstrap above already set the body class + effect CSS vars so
|
|
// the static-gradient patterns (dots, synapse) paint immediately; this
|
|
// block imports theme.js's canvas implementations for the dynamic
|
|
// patterns (rain, constellations, perlin-flow, petals, sparkles,
|
|
// embers, synapse pulses).
|
|
//
|
|
// IMPORTANT: theme.js's auto-init (`_initWithSync` → `initThemeUI`)
|
|
// early-returns at `#themeGrid` which doesn't exist on this page, so
|
|
// `applyBgPattern` would never fire on its own. We call it directly
|
|
// here against the pattern the bootstrap already chose.
|
|
try {
|
|
const tm = await import('/static/js/theme.js');
|
|
const pattern = window.__loginBgPattern;
|
|
if (pattern && tm.applyBgPattern) tm.applyBgPattern(pattern);
|
|
} catch (e) {
|
|
console.error('[login-bg] failed:', e);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|