CI / test (push) Successful in 2m51s
Spacing scale, 6/10px radii, flat bordered cards, pill badges, accent-tinted navigation, segmented tabs and a single control height, in light and dark. Class names are unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
174 lines
9.3 KiB
HTML
174 lines
9.3 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Sign in</title>
|
|
<style>
|
|
/* Design tokens: the only place raw colors, spacing and sizes are defined (same system as Lightwell). */
|
|
:root {
|
|
--accent:#0891b2; --accent-contrast:#fff;
|
|
--bg:#f7f8f9; --surface:#fff; --surface-2:#f0f2f4; --border:#e2e5e9; --border-strong:#c6ccd3;
|
|
--text:#16191d; --text-muted:#566069; --text-faint:#75808b;
|
|
--accent-text:color-mix(in srgb, var(--accent) 85%, #000);
|
|
--accent-soft:color-mix(in srgb, var(--accent) 10%, var(--surface));
|
|
--accent-hover:color-mix(in srgb, var(--accent) 86%, #000);
|
|
--focus:color-mix(in srgb, var(--accent) 65%, #fff);
|
|
--success:#1c6b3a; --success-bg:#e3f1e8; --warning:#85560a; --warning-bg:#f9eed6; --danger:#a3271e; --danger-bg:#f9e6e3;
|
|
--overlay:rgb(15 18 22 / .5); --shadow:0 8px 24px rgb(15 18 22 / .14);
|
|
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px; --s7:48px; --s8:64px;
|
|
--radius:6px; --radius-lg:10px;
|
|
--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:14px; --text-lg:16px; --text-xl:20px; --text-2xl:26px;
|
|
--control-h:36px; --sidebar-w:248px; --content-max:1180px; --ease:140ms ease;
|
|
color-scheme:light;
|
|
}
|
|
@media (prefers-color-scheme: dark) { :root {
|
|
--accent:#22b8d4; --accent-contrast:#04222a;
|
|
--bg:#0c0d0f; --surface:#141517; --surface-2:#1d1f22; --border:#2a2d31; --border-strong:#41454b;
|
|
--text:#eceef0; --text-muted:#aab1b9; --text-faint:#8a929b;
|
|
--accent-text:color-mix(in srgb, var(--accent) 80%, #fff); --accent-soft:color-mix(in srgb, var(--accent) 18%, var(--surface)); --accent-hover:color-mix(in srgb, var(--accent) 85%, #fff);
|
|
--success:#86d3a0; --success-bg:#16301f; --warning:#e8bd6b; --warning-bg:#33280f; --danger:#f19a92; --danger-bg:#3a1b18;
|
|
--overlay:rgb(0 0 0 / .65); --shadow:0 8px 24px rgb(0 0 0 / .5); color-scheme:dark;
|
|
} }
|
|
* { box-sizing:border-box; }
|
|
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/1.5 var(--font); display:grid; place-items:center; min-height:100vh; padding:var(--s4); -webkit-font-smoothing:antialiased; }
|
|
.card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:var(--s5); width:100%; max-width:400px; }
|
|
h1 { font-size:var(--text-xl); font-weight:600; letter-spacing:-.01em; margin:0 0 var(--s1); }
|
|
.sub { color:var(--text-muted); margin:0 0 var(--s4); font-size:var(--text-sm); }
|
|
.sec { border-top:1px solid var(--border); padding-top:var(--s4); margin-top:var(--s4); display:flex; flex-direction:column; gap:var(--s2); }
|
|
.sec:first-of-type { border-top:0; padding-top:0; margin-top:0; }
|
|
h2 { font-size:var(--text-xs); text-transform:uppercase; letter-spacing:.06em; color:var(--text-faint); margin:0; font-weight:600; }
|
|
input { background:var(--surface); color:var(--text); border:1px solid var(--border-strong); border-radius:var(--radius); padding:var(--s2) var(--s3); min-height:var(--control-h); font:inherit; 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:1px solid var(--accent); border-radius:var(--radius); font:inherit; font-weight:500; cursor:pointer; text-decoration:none; transition:background var(--ease); }
|
|
button:hover, a.btn:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
|
|
button.ghost, a.ghost { background:var(--surface); border:1px solid 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); min-height:18px; font-size:var(--text-sm); margin-top:var(--s3); }
|
|
.ok { color:var(--text-muted); font-size:var(--text-sm); }
|
|
[hidden] { display:none !important; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h1 id="title">Sign in</h1>
|
|
<p class="sub" id="sub"></p>
|
|
<div id="body"></div>
|
|
<div id="err"></div>
|
|
</div>
|
|
<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 || '';
|
|
|
|
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, placeholder:label, 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, i, h('button', {onclick:go}, 'Continue'));
|
|
}
|
|
|
|
function emailForm() {
|
|
const email = h('input', {type:'email', placeholder:'you@example.com', autocomplete:'email'});
|
|
const code = h('input', {placeholder:'6-digit code', inputmode:'numeric', hidden:true});
|
|
const msg = h('div', {class:'ok'});
|
|
const go = h('button', {}, 'Email me a code');
|
|
go.onclick = async () => {
|
|
try {
|
|
if (code.hidden) {
|
|
const j = await post(`/auth/r/${id}/email/start`, { email: email.value, return: ret });
|
|
msg.textContent = j.status; code.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', email, code, msg, go);
|
|
}
|
|
|
|
function ssoForm(info) {
|
|
const kids = [];
|
|
if (info.user) {
|
|
if (info.user.allowed) {
|
|
kids.push(h('div', {class:'ok'}, `Signed in as ${info.user.email}`), h('button', {onclick:() => finish('sso', {})}, 'Continue'));
|
|
} else {
|
|
kids.push(h('div', {class:'ok'}, `${info.user.email} doesn't have access to this resource.`));
|
|
}
|
|
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', placeholder:'Email', autocomplete:'username'});
|
|
const pw = h('input', {type:'password', placeholder:'Password', autocomplete:'current-password'});
|
|
const code = h('input', {placeholder:'Authenticator code', inputmode:'numeric', autocomplete:'one-time-code', 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; code.hidden = false; email.hidden = pw.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(email, pw, code, 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 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;
|
|
$('#sub').textContent = `${info.host} is protected. Sign in to continue.`;
|
|
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 available.');
|
|
} catch (e) { $('#title').textContent = 'Unavailable'; err(e.message); }
|
|
}
|
|
main();
|
|
</script>
|
|
</body>
|
|
</html>
|