CI / test (push) Successful in 2m27s
Warm neutrals, teal accent, 15px type, and Lightwell's component shapes. Status is a badge with text instead of a colored dot. Every destructive action asks first, naming the object and what is lost. Dialogs trap focus, close on Escape and return focus. The sidebar is a drawer on phones, there is a theme switch, loading shows skeleton rows, and empty states explain what belongs there. Emoji and dashes are gone, and server errors are capitalized and tell visitors what to do next. Adds scripts/lint-design.mjs and docs/design.md. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
200 lines
11 KiB
HTML
200 lines
11 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="color-scheme" content="light dark">
|
|
<title>Sign in</title>
|
|
<style>
|
|
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
|
|
:root {
|
|
--accent:#1f5c5c; --accent-contrast:#ffffff;
|
|
--bg:#f6f5f3; --surface:#ffffff; --surface-2:#efede9; --border:#e0ddd7; --border-strong:#c4c0b8;
|
|
--text:#1d1c1a; --text-muted:#5f5b54; --text-faint:#7a766e;
|
|
--accent-text:var(--accent);
|
|
--accent-soft:color-mix(in srgb, var(--accent) 9%, var(--surface));
|
|
--accent-hover:color-mix(in srgb, var(--accent) 88%, #000000);
|
|
--focus:color-mix(in srgb, var(--accent) 70%, #ffffff);
|
|
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3; --neutral-bg:#eceae6;
|
|
--overlay:rgb(20 19 17 / 0.55); --shadow:0 8px 24px rgb(20 19 17 / 0.14);
|
|
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
|
|
--radius:6px; --radius-lg:10px; --border-w:1px;
|
|
--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
|
--font-mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
|
|
--text-xs:12px; --text-sm:13px; --text-base:15px; --text-lg:18px; --text-xl:22px; --text-2xl:28px; --leading:1.5;
|
|
--sidebar-w:232px; --content-max:1120px; --touch:44px; --control-h:38px;
|
|
--z-sticky:20; --z-overlay:100; --z-toast:200; --ease:140ms ease;
|
|
color-scheme:light;
|
|
}
|
|
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) {
|
|
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
|
|
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
|
|
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
|
|
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
|
|
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
|
|
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
|
|
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
|
|
} }
|
|
:root[data-theme="dark"] {
|
|
--bg:#131312; --surface:#1b1b19; --surface-2:#252523; --border:#33322e; --border-strong:#4a4943;
|
|
--text:#ecebe7; --text-muted:#adaaa2; --text-faint:#8f8c84;
|
|
--accent-text:color-mix(in srgb, var(--accent) 45%, #ffffff);
|
|
--accent-soft:color-mix(in srgb, var(--accent) 22%, var(--surface));
|
|
--accent-hover:color-mix(in srgb, var(--accent) 85%, #ffffff);
|
|
--success:#86d3a0; --success-bg:#17301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18; --neutral-bg:#2b2a27;
|
|
--overlay:rgb(0 0 0 / 0.65); --shadow:0 8px 24px rgb(0 0 0 / 0.5); color-scheme:dark;
|
|
}
|
|
/* tokens:end */
|
|
|
|
*, *::before, *::after { box-sizing:border-box; }
|
|
html { -webkit-text-size-adjust:100%; }
|
|
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/var(--leading) var(--font); display:grid; place-items:center; min-height:100vh; padding:var(--s4); -webkit-font-smoothing:antialiased; }
|
|
.card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); width:100%; max-width:440px; }
|
|
h1 { font-size:var(--text-xl); font-weight:600; letter-spacing:-.01em; line-height:1.25; margin:0 0 var(--s1); }
|
|
h2 { font-size:var(--text-base); font-weight:600; margin:0; }
|
|
.lead { color:var(--text-muted); margin:0 0 var(--s4); }
|
|
.sec { border-top:var(--border-w) solid var(--border); padding-top:var(--s4); margin-top:var(--s4); display:flex; flex-direction:column; gap:var(--s3); }
|
|
.sec:first-of-type { border-top:0; padding-top:0; margin-top:0; }
|
|
label { display:flex; flex-direction:column; gap:var(--s1); font-size:var(--text-sm); font-weight:600; }
|
|
input { background:var(--surface); color:var(--text); border:var(--border-w) solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; font-weight:400; width:100%; }
|
|
input:focus { outline:2px solid var(--focus); outline-offset:0; border-color:var(--accent); }
|
|
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
|
|
button, a.btn { display:flex; align-items:center; justify-content:center; min-height:var(--control-h); padding:0 var(--s4); background:var(--accent); color:var(--accent-contrast); border:var(--border-w) solid var(--accent); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; text-decoration:none; transition:background var(--ease), border-color var(--ease); }
|
|
button:hover, a.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
|
|
button.ghost, a.ghost { background:var(--surface); border-color:var(--border-strong); color:var(--text); }
|
|
button.ghost:hover, a.ghost:hover { background:var(--surface-2); border-color:var(--border-strong); }
|
|
#err { color:var(--danger); font-size:var(--text-sm); margin-top:var(--s3); }
|
|
#err:empty { display:none; }
|
|
.hint { color:var(--text-muted); font-size:var(--text-sm); margin:0; }
|
|
[hidden] { display:none !important; }
|
|
@media (prefers-reduced-motion: reduce) { * { transition:none !important; } }
|
|
@media (pointer: coarse) { button, a.btn, input { min-height:var(--touch); } input { font-size:16px; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="card">
|
|
<h1 id="title">Sign in</h1>
|
|
<p class="lead" id="sub"></p>
|
|
<div id="body"></div>
|
|
<div id="err" role="alert"></div>
|
|
</main>
|
|
<script>
|
|
const id = location.pathname.split('/')[3];
|
|
const params = new URLSearchParams(location.search);
|
|
const ret = params.get('return') || '';
|
|
const $ = s => document.querySelector(s);
|
|
const body = $('#body');
|
|
|
|
function h(tag, props, ...kids) {
|
|
const el = document.createElement(tag);
|
|
for (const [k, v] of Object.entries(props || {})) {
|
|
if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
|
else if (v === true) el.setAttribute(k, '');
|
|
else if (v != null && v !== false) el.setAttribute(k, v);
|
|
}
|
|
for (const k of kids.flat()) if (k != null) el.append(k.nodeType ? k : document.createTextNode(k));
|
|
return el;
|
|
}
|
|
const err = m => $('#err').textContent = m || '';
|
|
let n = 0;
|
|
// a visible label above the control, as the design language requires
|
|
const field = (label, input) => { input.id = input.id || 'f' + (++n); return h('label', {for:input.id}, label, input); };
|
|
|
|
async function post(path, data) {
|
|
err('');
|
|
const r = await fetch(path, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data) });
|
|
const j = await r.json().catch(() => ({}));
|
|
if (!r.ok) throw new Error(j.error || r.statusText);
|
|
return j;
|
|
}
|
|
async function finish(path, data) {
|
|
try {
|
|
const j = await post(`/auth/r/${id}/${path}`, { ...data, return: ret });
|
|
if (j.redirect) location.href = j.redirect;
|
|
} catch (e) { err(e.message); }
|
|
}
|
|
|
|
function section(title, ...kids) { return h('div', {class:'sec'}, h('h2', {}, title), ...kids); }
|
|
|
|
function passwordForm(kind, label, type) {
|
|
const i = h('input', {type, autocomplete: kind === 'password' ? 'current-password' : 'off', inputmode: kind === 'pin' ? 'numeric' : null});
|
|
const go = () => finish(kind, { [kind]: i.value });
|
|
i.addEventListener('keydown', e => e.key === 'Enter' && go());
|
|
return section(label, field(label, i), h('button', {onclick:go}, 'Continue'));
|
|
}
|
|
|
|
function emailForm() {
|
|
const email = h('input', {type:'email', placeholder:'you@example.com', autocomplete:'email'});
|
|
const code = h('input', {inputmode:'numeric', autocomplete:'one-time-code'});
|
|
const codeField = field('6-digit code', code); codeField.hidden = true;
|
|
const msg = h('p', {class:'hint'});
|
|
const go = h('button', {}, 'Email me a code');
|
|
go.onclick = async () => {
|
|
try {
|
|
if (codeField.hidden) {
|
|
const j = await post(`/auth/r/${id}/email/start`, { email: email.value, return: ret });
|
|
msg.textContent = j.status; codeField.hidden = false; go.textContent = 'Verify code'; code.focus();
|
|
} else await finish('email/verify', { email: email.value, code: code.value });
|
|
} catch (e) { err(e.message); }
|
|
};
|
|
return section('Email code', field('Email address', email), codeField, msg, go);
|
|
}
|
|
|
|
function ssoForm(info) {
|
|
const kids = [];
|
|
if (info.user) {
|
|
if (info.user.allowed) {
|
|
kids.push(h('p', {class:'hint'}, `Signed in as ${info.user.email}.`), h('button', {onclick:() => finish('sso', {})}, 'Continue'));
|
|
} else {
|
|
kids.push(h('p', {class:'hint'}, `${info.user.email} does not have access to this resource. Ask the person who shared it to add you.`));
|
|
}
|
|
kids.push(h('button', {class:'ghost', onclick: async () => { await fetch('/api/logout', {method:'POST'}); location.reload(); }}, 'Use a different account'));
|
|
return section('Account', ...kids);
|
|
}
|
|
const email = h('input', {type:'email', autocomplete:'username'});
|
|
const pw = h('input', {type:'password', autocomplete:'current-password'});
|
|
const code = h('input', {inputmode:'numeric', autocomplete:'one-time-code'});
|
|
const emailField = field('Email address', email), pwField = field('Password', pw), codeField = field('Authenticator code', code);
|
|
codeField.hidden = true;
|
|
let ticket = null;
|
|
const go = async () => {
|
|
try {
|
|
let j;
|
|
if (ticket) j = await post('/api/login/totp', { ticket, code: code.value });
|
|
else j = await post('/api/login', { email: email.value, password: pw.value });
|
|
if (j.needTotp) { ticket = j.ticket; codeField.hidden = false; emailField.hidden = pwField.hidden = true; code.focus(); return; }
|
|
location.reload();
|
|
} catch (e) { err(e.message); }
|
|
};
|
|
[email, pw, code].forEach(i => i.addEventListener('keydown', e => e.key === 'Enter' && go()));
|
|
kids.push(emailField, pwField, codeField, h('button', {onclick:go}, 'Sign in'));
|
|
for (const p of info.providers || []) {
|
|
const back = `/auth/r/${id}?return=${encodeURIComponent(ret)}`;
|
|
kids.push(h('a', {class:'btn ghost', href:`/auth/oidc/${p.id}/start?return=${encodeURIComponent(back)}`}, `Continue with ${p.name}`));
|
|
}
|
|
return section('Account', ...kids);
|
|
}
|
|
|
|
async function main() {
|
|
// share links skip the form
|
|
const share = params.get('share');
|
|
if (share) { $('#title').textContent = 'Checking your link'; return finish('share', { token: share }); }
|
|
try {
|
|
const r = await fetch(`/auth/r/${id}/info?return=${encodeURIComponent(ret)}`);
|
|
const info = await r.json();
|
|
if (!r.ok) throw new Error(info.error);
|
|
$('#title').textContent = info.name;
|
|
document.title = 'Sign in to ' + info.name;
|
|
$('#sub').textContent = `Sign in to open ${info.host}.`;
|
|
if (info.sso) body.append(ssoForm(info));
|
|
if (info.password) body.append(passwordForm('password', 'Password', 'password'));
|
|
if (info.pin) body.append(passwordForm('pin', 'PIN', 'password'));
|
|
if (info.email) body.append(emailForm());
|
|
if (!body.children.length) err('No sign-in method is set up for this page. Ask the person who shared it with you.');
|
|
} catch (e) { $('#title').textContent = 'This page is unavailable'; err(e.message); }
|
|
}
|
|
main();
|
|
</script>
|
|
</body>
|
|
</html>
|