201 lines
29 KiB
HTML
201 lines
29 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<script src="./support.js"></script>
|
|
</head>
|
|
<body>
|
|
<x-dc>
|
|
<helmet>
|
|
<meta name="design_doc_mode" content="canvas">
|
|
<link rel="icon" href="brand/favicon.svg">
|
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
<style>body{margin:0;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px}*{box-sizing:border-box}a{color:inherit;text-underline-offset:3px}a:hover{opacity:.8}code{font-family:'IBM Plex Mono',monospace;font-size:12px}</style>
|
|
</helmet>
|
|
<div ref="{{ rootRef }}" data-screen-label="Auth screens" style="--ui-bg:#ffffff;--ui-bg-muted:#fafafa;--ui-bg-elevated:#f3f2f0;--ui-bg-accented:#e6e4e0;--ui-bg-inverted:#111111;--ui-text-dimmed:#999999;--ui-text-muted:#666666;--ui-text-toned:#444444;--ui-text:#222222;--ui-text-highlighted:#111111;--ui-text-inverted:#ffffff;--ui-border:#e6e4e0;--ui-border-muted:#f3f2f0;--ui-border-accented:#8a8783;--ui-primary:#111111;--ui-secondary:#635643;--ui-success:#315b33;--ui-info:#34536d;--ui-warning:#6c4700;--ui-error:#882c21;--rc-sand:#e8dfd1;--rc-success-soft:#eaefea;--rc-info-soft:#ebeef0;--rc-warning-soft:#f0ede6;--rc-error-soft:#f3eae9;--rc-overlay:rgb(10 10 10 / 0.4);--rc-shadow-pop:0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);--rc-shadow-modal:0 16px 48px rgb(17 17 17 / 0.18);--rc-canvas:#ecebe8;background:var(--rc-canvas);color:var(--ui-text);padding:64px;display:flex;flex-direction:column;gap:72px;width:max-content;min-width:100%">
|
|
|
|
<div style="display:flex;align-items:flex-end;gap:32px;max-width:1400px">
|
|
<div style="flex:1;display:flex;flex-direction:column;gap:10px">
|
|
<h1 style="margin:0;font:600 36px/44px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:var(--ui-text-highlighted)">Screens: public and auth</h1>
|
|
<p style="margin:0;max-width:880px;font-size:16px;line-height:26px;color:var(--ui-text-toned);text-wrap:pretty">Every state of the seven public routes, at 1440 px (shown at 50%) and 390 px (shown at 75%). Use the switch on the right to see all of them in dark mode. Final copy is written into each state. The back-office screens and global states are on <a href="RenewCMS Screens Back-office.dc.html">the back-office board</a>, and the emails are on <a href="RenewCMS Emails.dc.html">the email board</a>.</p>
|
|
</div>
|
|
<div role="radiogroup" aria-label="Theme" style="display:flex;border:1px solid var(--ui-border-accented);background:var(--ui-bg)">
|
|
<sc-for list="{{ modes }}" as="m" hint-placeholder-count="3">
|
|
<button role="radio" aria-checked="{{ m.on }}" onClick="{{ m.pick }}" style="all:unset;cursor:pointer;height:36px;padding:0 14px;display:flex;align-items:center;font-weight:500;font-size:14px;background:{{ m.bg }};color:{{ m.fg }}" style-focus-visible="outline:2px solid var(--ui-primary);outline-offset:2px">{{ m.label }}</button>
|
|
</sc-for>
|
|
</div>
|
|
</div>
|
|
|
|
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="6">
|
|
<section data-screen-label="{{ g.label }}" style="display:flex;flex-direction:column;gap:28px">
|
|
<div style="display:flex;flex-direction:column;gap:6px;max-width:1100px">
|
|
<h2 style="margin:0;font:600 28px/36px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ g.title }}</h2>
|
|
<div style="display:flex;gap:16px;flex-wrap:wrap;font:400 14px/22px 'IBM Plex Mono',monospace;color:var(--ui-text-toned)"><span>{{ g.route }}</span><span style="color:var(--ui-text-muted)">{{ g.api }}</span></div>
|
|
<sc-if value="{{ g.note }}"><p style="margin:4px 0 0;font-size:15px;line-height:24px;color:var(--ui-text-toned);text-wrap:pretty">{{ g.note }}</p></sc-if>
|
|
</div>
|
|
<div style="display:flex;flex-wrap:wrap;gap:40px;align-items:flex-start">
|
|
<sc-for list="{{ g.states }}" as="st" hint-placeholder-count="4">
|
|
<div style="display:flex;flex-direction:column;gap:12px">
|
|
<div style="display:flex;flex-direction:column;gap:2px;max-width:1040px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.name }}</span><span style="font-size:13px;color:var(--ui-text-muted)">{{ st.trigger }}</span></div>
|
|
<div style="display:flex;gap:24px;align-items:flex-start">
|
|
<div style="width:720px;height:450px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0">
|
|
<div style="width:1440px;height:900px;zoom:0.5;background:var(--ui-bg-muted);padding-top:108px;position:relative;display:flex;flex-direction:column;align-items:center">
|
|
<dc-import name="AuthCard" s="{{ st }}" mobile="{{ false }}" hint-size="400px,520px"></dc-import>
|
|
<sc-if value="{{ st.dialog }}">
|
|
<div style="position:absolute;inset:0;background:var(--rc-overlay);display:flex;align-items:flex-start;justify-content:center;padding-top:220px">
|
|
<div role="alertdialog" style="width:440px;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal)">
|
|
<div style="padding:24px 24px 0;display:flex;flex-direction:column;gap:8px"><span style="font:600 16px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.dialog.title }}</span><span style="color:var(--ui-text-toned);font-size:14px;line-height:22px">{{ st.dialog.text }}</span></div>
|
|
<div style="padding:24px;display:flex;justify-content:flex-end;gap:8px"><span style="height:32px;padding:0 12px;display:flex;align-items:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500">{{ st.dialog.cancel }}</span><span style="height:32px;padding:0 12px;display:flex;align-items:center;gap:6px;background:var(--ui-error);color:var(--ui-bg);font-weight:500">{{ st.dialog.confirm }}</span></div>
|
|
</div>
|
|
</div>
|
|
</sc-if>
|
|
<sc-if value="{{ st.toast }}">
|
|
<div style="position:absolute;right:24px;bottom:24px;width:356px;display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop)"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;margin-top:2px;width:16px;height:16px;background-color:{{ st.toast.fg }};-webkit-mask:url('https://api.iconify.design/lucide/{{ st.toast.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ st.toast.icon }}.svg') center / contain no-repeat"></span><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;color:var(--ui-text-highlighted)">{{ st.toast.title }}</span><span style="color:var(--ui-text-muted)">{{ st.toast.text }}</span></div></div>
|
|
</sc-if>
|
|
</div>
|
|
</div>
|
|
<div style="width:293px;height:633px;overflow:hidden;border:1px solid var(--ui-border);flex-shrink:0">
|
|
<div style="width:390px;height:844px;zoom:0.75;background:var(--ui-bg);padding-top:40px;position:relative;display:flex;flex-direction:column">
|
|
<dc-import name="AuthCard" s="{{ st }}" mobile="{{ true }}" hint-size="390px,600px"></dc-import>
|
|
<sc-if value="{{ st.dialog }}">
|
|
<div style="position:absolute;inset:0;background:var(--rc-overlay);display:flex;align-items:flex-end;padding:16px">
|
|
<div role="alertdialog" style="width:100%;background:var(--ui-bg);box-shadow:var(--rc-shadow-modal)">
|
|
<div style="padding:20px 20px 0;display:flex;flex-direction:column;gap:8px"><span style="font:600 17px/24px 'IBM Plex Sans',sans-serif;color:var(--ui-text-highlighted)">{{ st.dialog.title }}</span><span style="color:var(--ui-text-toned);font-size:15px;line-height:23px">{{ st.dialog.text }}</span></div>
|
|
<div style="padding:20px;display:flex;flex-direction:column;gap:8px"><span style="height:44px;display:flex;align-items:center;justify-content:center;background:var(--ui-error);color:var(--ui-bg);font-weight:500;font-size:15px">{{ st.dialog.confirm }}</span><span style="height:44px;display:flex;align-items:center;justify-content:center;border:1px solid var(--ui-border-accented);color:var(--ui-text-highlighted);font-weight:500;font-size:15px">{{ st.dialog.cancel }}</span></div>
|
|
</div>
|
|
</div>
|
|
</sc-if>
|
|
<sc-if value="{{ st.toast }}">
|
|
<div style="position:absolute;left:16px;right:16px;bottom:16px;display:flex;gap:10px;padding:12px 14px;background:var(--ui-bg);border:1px solid var(--ui-border);box-shadow:var(--rc-shadow-pop);font-size:14px"><span aria-hidden="true" style="display:inline-block;flex-shrink:0;margin-top:3px;width:16px;height:16px;background-color:{{ st.toast.fg }};-webkit-mask:url('https://api.iconify.design/lucide/{{ st.toast.icon }}.svg') center / contain no-repeat;mask:url('https://api.iconify.design/lucide/{{ st.toast.icon }}.svg') center / contain no-repeat"></span><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;color:var(--ui-text-highlighted)">{{ st.toast.title }}</span><span style="color:var(--ui-text-muted)">{{ st.toast.text }}</span></div></div>
|
|
</sc-if>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</sc-for>
|
|
</div>
|
|
</section>
|
|
</sc-for>
|
|
|
|
</div>
|
|
</x-dc>
|
|
<script type="text/x-dc" data-dc-script data-props="{"theme":{"editor":"enum","options":["system","light","dark"],"default":"light","tsType":"'system' | 'light' | 'dark'"}}">
|
|
class Component extends DCLogic {
|
|
state = { mode: this.props.theme ?? 'light' }
|
|
rootRef = React.createRef()
|
|
light = { '--ui-bg': '#ffffff', '--ui-bg-muted': '#fafafa', '--ui-bg-elevated': '#f3f2f0', '--ui-bg-accented': '#e6e4e0', '--ui-bg-inverted': '#111111', '--ui-text-dimmed': '#999999', '--ui-text-muted': '#666666', '--ui-text-toned': '#444444', '--ui-text': '#222222', '--ui-text-highlighted': '#111111', '--ui-text-inverted': '#ffffff', '--ui-border': '#e6e4e0', '--ui-border-muted': '#f3f2f0', '--ui-border-accented': '#8a8783', '--ui-primary': '#111111', '--ui-secondary': '#635643', '--ui-success': '#315b33', '--ui-info': '#34536d', '--ui-warning': '#6c4700', '--ui-error': '#882c21', '--rc-sand': '#e8dfd1', '--rc-success-soft': '#eaefea', '--rc-info-soft': '#ebeef0', '--rc-warning-soft': '#f0ede6', '--rc-error-soft': '#f3eae9', '--rc-overlay': 'rgb(10 10 10 / 0.4)', '--rc-shadow-pop': '0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06)', '--rc-shadow-modal': '0 16px 48px rgb(17 17 17 / 0.18)', '--rc-canvas': '#ecebe8' }
|
|
dark = { '--ui-bg': '#111111', '--ui-bg-muted': '#0a0a0a', '--ui-bg-elevated': '#1a1a1a', '--ui-bg-accented': '#222222', '--ui-bg-inverted': '#fafafa', '--ui-text-dimmed': '#666666', '--ui-text-muted': '#999999', '--ui-text-toned': '#cfccc7', '--ui-text': '#e6e4e0', '--ui-text-highlighted': '#fafafa', '--ui-text-inverted': '#111111', '--ui-border': '#333333', '--ui-border-muted': '#222222', '--ui-border-accented': '#666666', '--ui-primary': '#fafafa', '--ui-secondary': '#d4c5b0', '--ui-success': '#a3caa3', '--ui-info': '#a5c2db', '--ui-warning': '#dfb67d', '--ui-error': '#fba394', '--rc-sand': '#d4c5b0', '--rc-success-soft': '#1f241f', '--rc-info-soft': '#202428', '--rc-warning-soft': '#2a251e', '--rc-error-soft': '#2c201f', '--rc-overlay': 'rgb(0 0 0 / 0.6)', '--rc-shadow-pop': '0 4px 16px rgb(0 0 0 / 0.5)', '--rc-shadow-modal': '0 16px 48px rgb(0 0 0 / 0.6)', '--rc-canvas': '#1c1c1c' }
|
|
resolved() {
|
|
if (this.state.mode === 'system') return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
return this.state.mode
|
|
}
|
|
apply() {
|
|
const el = this.rootRef.current
|
|
if (!el) return
|
|
const vars = this.resolved() === 'dark' ? this.dark : this.light
|
|
for (const k in vars) el.style.setProperty(k, vars[k])
|
|
document.body.style.background = vars['--rc-canvas']
|
|
}
|
|
componentDidMount() { this.apply() }
|
|
componentDidUpdate(p) {
|
|
if (p.theme !== this.props.theme && this.props.theme) this.setState({ mode: this.props.theme })
|
|
this.apply()
|
|
}
|
|
groups() {
|
|
const U = (o = {}) => ({ label: 'Username', ph: 'Your username', ...o })
|
|
const P = (o = {}) => ({ label: 'Password', pw: true, ...o })
|
|
const E = (o = {}) => ({ label: 'Email', ph: 'you@example.com', ...o })
|
|
const pwHint = s => [{ t: 'At least 8 characters', s }]
|
|
const toast = (tone, icon, title, text) => ({ fg: 'var(--ui-' + tone + ')', icon, title, text })
|
|
const login = { title: 'Log in', desc: 'Welcome back. Log in to manage your articles.', submit: { t: 'Log in' } }
|
|
const loginF = (u, p) => [U(u), P({ link: 'Forgot password?', ...p })]
|
|
const reg = { title: 'Create the owner account', desc: 'This creates the single admin account for this site. Once it\'s verified, registration closes.', submit: { t: 'Create account' }, footer: { pre: 'Already set up?', link: 'Log in' } }
|
|
const regF = (u, e, p) => [U({ ph: 'e.g. marta', count: ((u && u.value) || '').length + ' / 20', help: 'Letters and numbers, 3 to 20 characters. You\'ll use it to log in.', ...u }), E({ help: 'We\'ll send a verification link here.', ...e }), P({ hints: pwHint('unmet'), ...p })]
|
|
const inv = { title: 'Accept your invite', desc: 'Choose a username and password to finish setting up your account.', submit: { t: 'Accept invite' }, footer: { pre: 'Already have an account?', link: 'Log in' } }
|
|
const invF = (u, p) => [E({ value: 'lea.martin@renew.example', ro: true, help: 'From your invitation.' }), U({ ph: 'e.g. lea', count: ((u && u.value) || '').length + ' / 20', help: 'Letters and numbers, 3 to 20 characters. You\'ll use it to log in.', ...u }), P({ hints: pwHint('unmet'), ...p })]
|
|
const pendingResult = email => ({ tone: 'success', icon: 'mail-check', title: 'Check your email', text: email ? 'We sent a verification link to ' + email + '. It expires in 2 hours.' : 'We sent a verification link to the email you registered with. It expires in 2 hours.', note: 'Opened it on another device? That works too; you\'ll log in there first. Can\'t find it? Check your spam folder. If the link has expired, cancel your registration and start again.', actions: [{ t: 'Log out', secondary: true }, { t: 'Cancel registration', secondary: true }] })
|
|
const cancelDialog = (loading) => ({ title: 'Cancel your registration?', text: 'Your account will be deleted. You can register again with the same or a different email.', confirm: loading ? 'Cancelling…' : 'Cancel registration', cancel: 'Keep my account' })
|
|
const reqBase = { title: 'Reset your password', desc: 'Enter the email for your account and we\'ll send you a link to choose a new password.', submit: { t: 'Send reset link' }, below: 'Back to log in' }
|
|
const rv = { title: 'Choose a new password', desc: 'Pick something you haven\'t used here before. You\'ll log in with it next.', submit: { t: 'Change password' } }
|
|
const rvF = (p, c, hints) => [E({ value: 'marta@renew.example', ro: true, help: 'From your reset link.' }), P({ label: 'New password', hints: [{ t: 'At least 8 characters', s: hints[0] }], ...p }), P({ label: 'Confirm new password', hints: [{ t: 'Passwords match', s: hints[1] }], ...c })]
|
|
const expired = { tone: 'error', icon: 'link-2-off', title: 'This reset link has expired', text: 'Reset links work once, for 1 hour. Request a new one and use it within the hour.', actions: [{ t: 'Request a new link' }, { t: 'Back to log in', secondary: true }] }
|
|
return [
|
|
{ label: 'Log in', title: 'Log in', route: '/login', api: 'GET /v1/auth/registration (pending) · POST /v1/auth/login', note: 'After logging in, you go back to the page you originally asked for (?redirect=), or to /home. An unverified account goes to /register/pending. "Create the owner account" appears only when registration is open.', states: [
|
|
{ ...login, name: 'Default', trigger: 'Registration is closed, which is the usual case.', fields: loginF({}, {}) },
|
|
{ ...login, name: 'Fresh install', trigger: 'GET /v1/auth/registration returns { open: true }.', fields: loginF({}, {}), footer: { pre: 'First time here?', link: 'Create the owner account' }, pending: true },
|
|
{ ...login, name: 'Validation errors', trigger: 'Submit with a short username and an empty password. Nothing is sent.', fields: loginF({ value: 'ma', err: 'Use at least 3 characters.' }, { err: 'Enter your password.' }) },
|
|
{ ...login, name: 'Submitting', trigger: 'The request is in flight. Fields and button are disabled.', fields: loginF({ value: 'marta' }, { value: 'limewash2026' }), submit: { t: 'Logging in…', loading: true } },
|
|
{ ...login, name: 'Wrong credentials (403)', trigger: 'The API doesn\'t say which field is wrong, so neither does the message. The password is cleared and gets focus.', notice: { tone: 'error', icon: 'circle-x', title: 'That username and password don\'t match', text: 'Check both and try again. Passwords are case-sensitive.' }, fields: loginF({ value: 'marta' }, { focus: true }) },
|
|
{ ...login, name: 'Too many attempts (429)', trigger: 'Retry-After is counted down. The button re-enables at zero.', notice: { tone: 'info', icon: 'timer', title: 'Too many attempts', text: 'For your security, wait a moment before trying again.' }, fields: loginF({ value: 'marta' }, { value: 'limewash' }), submit: { t: 'Try again in 0:42', disabled: true }, pending: true },
|
|
{ ...login, name: 'Session expired', trigger: 'Redirected from a 401 with ?redirect=/article/12/edit&reason=expired. Unsaved edits are kept in the browser.', notice: { tone: 'info', icon: 'clock', title: 'Your session expired', text: 'Log in again to pick up where you left off. Unsaved changes are kept on this device.' }, fields: loginF({ value: 'marta' }, { focus: true }) },
|
|
{ ...login, name: 'Password changed', trigger: 'Arrived from a successful reset with ?reason=password-changed.', notice: { tone: 'success', icon: 'circle-check', title: 'Password changed', text: 'Log in with your new password.' }, fields: loginF({}, {}) },
|
|
{ ...login, name: 'Server or network error', trigger: 'A 500 or no response. The fields keep their values.', notice: { tone: 'error', icon: 'circle-x', title: 'Couldn\'t reach RenewCMS', text: 'Check your connection and try again.' }, fields: loginF({ value: 'marta' }, { value: 'limewash2026' }) }
|
|
] },
|
|
{ label: 'Register', title: 'Create the owner account', route: '/register', api: 'POST /v1/auth/register', note: 'Seen once, on a fresh install. After registering, the new user is logged in but not yet verified, and goes to /register/pending.', states: [
|
|
{ ...reg, name: 'Default', trigger: 'Registration is open.', fields: regF({}, {}, {}) },
|
|
{ ...reg, name: 'Validation errors', trigger: 'Checked on blur and on submit, using the same rules as the API.', fields: regF({ value: 'ma', err: 'Use 3 to 20 characters.', countErr: true, help: '' }, { value: 'marta@renew', err: 'Enter a full email address, like name@example.com.', help: '' }, { value: 'lime', hints: pwHint('err') }) },
|
|
{ ...reg, name: 'Filled, valid', trigger: 'Every rule is met and the checklist item turns green.', fields: regF({ value: 'marta' }, { value: 'marta@renew.example' }, { value: 'limewash2026', shown: true, hints: pwHint('met') }) },
|
|
{ ...reg, name: 'Submitting', trigger: 'Fields and button are disabled.', fields: regF({ value: 'marta' }, { value: 'marta@renew.example' }, { value: 'limewash2026', hints: pwHint('met') }), submit: { t: 'Creating account…', loading: true } },
|
|
{ name: 'Registration closed (403)', trigger: 'Someone already verified an owner account. The form is replaced.', result: { tone: 'info', icon: 'user-x', title: 'This site already has an owner', text: 'Log in instead.', actions: [{ t: 'Go to log in' }] } },
|
|
{ ...reg, name: 'Server error', trigger: 'A 500 or no response. The values are kept.', notice: { tone: 'error', icon: 'circle-x', title: 'Couldn\'t create the account', text: 'Something went wrong on the server. Try again in a moment.' }, fields: regF({ value: 'marta' }, { value: 'marta@renew.example' }, { value: 'limewash2026', hints: pwHint('met') }) }
|
|
] },
|
|
{ label: 'Pending', title: 'Check your email', route: '/register/pending', api: 'DELETE /v1/auth/register · POST /v1/auth/logout', note: 'Any back-office route visited by an unverified account lands here. There\'s no way to resend the email because the API doesn\'t offer one, so the note explains the way out.', states: [
|
|
{ name: 'Default', trigger: 'Straight after registering, when the email is known from the form.', result: pendingResult('marta@renew.example') },
|
|
{ name: 'Email unknown', trigger: 'Arrived after logging in as an unverified user.', result: pendingResult(null) },
|
|
{ name: 'Confirm cancel', trigger: '"Cancel registration" opens the destructive ConfirmDialog.', result: pendingResult('marta@renew.example'), dialog: cancelDialog(false) },
|
|
{ name: 'Cancelling', trigger: 'DELETE in flight. The dialog can\'t be dismissed.', result: pendingResult('marta@renew.example'), dialog: cancelDialog(true) },
|
|
{ ...reg, name: 'Cancelled', trigger: '200 → /register, with a toast.', fields: regF({}, {}, {}), toast: toast('success', 'circle-check', 'Registration cancelled', 'You can create the owner account again.') },
|
|
{ name: 'Already verified (403)', trigger: 'Verified on another device. Goes to /home; shown here as the toast over the destination.', result: { tone: 'success', icon: 'shield-check', title: 'You\'re verified', text: 'Taking you to your dashboard…', actions: [] }, toast: toast('info', 'info', 'Your account is already verified', 'There\'s nothing to cancel.') }
|
|
] },
|
|
{ label: 'Verify', title: 'Verify account', route: '/register/validate?c=CODE', api: 'POST /v1/auth/verify', note: 'Opened from the verification email, and submits automatically on load. This is the only full-card spinner in the product.', states: [
|
|
{ name: 'Verifying', trigger: 'On load, with a code in the URL.', spinner: 'Verifying your account…' },
|
|
{ name: 'Success', trigger: '200 → /home after 800 ms, with a toast.', result: { tone: 'success', icon: 'shield-check', title: 'Your account is verified', text: 'Taking you to your dashboard…', actions: [] }, toast: toast('success', 'circle-check', 'Your account is verified', 'Welcome to RenewCMS.') },
|
|
{ name: 'Invalid or expired (403)', trigger: 'The code is wrong, used, or older than 2 hours.', result: { tone: 'error', icon: 'link-2-off', title: 'This verification link doesn\'t work', text: 'It may have expired (links last 2 hours) or already been used.', note: 'Cancel your registration and start over to get a new link.', actions: [{ t: 'Cancel registration and start over' }, { t: 'Log out', secondary: true }] } },
|
|
{ name: 'No session (401)', trigger: 'Opened in another browser or device. Log in inline, then verification retries automatically with the same code.', title: 'Log in to finish verifying', desc: 'You opened this link somewhere you\'re not logged in. Log in here and we\'ll verify your account right after.', fields: [U(), P()], submit: { t: 'Log in and verify' } },
|
|
{ name: 'No session, retrying', trigger: 'Logged in inline. Now verifying.', spinner: 'Verifying your account…' },
|
|
{ name: 'Missing code', trigger: 'The link has no ?c= parameter. No request is made.', result: { tone: 'error', icon: 'link-2-off', title: 'This link is incomplete', text: 'The verification code is missing. Open the link from your email again, or copy the full address into your browser.', actions: [{ t: 'Go to log in', secondary: true }] } }
|
|
] },
|
|
{ label: 'Accept invite', title: 'Accept invite', route: '/invite/accept?c=CODE', api: 'GET /v1/auth/invite?c= (pending) · POST /v1/auth/invite/accept (pending)', note: 'Opened from the invitation email. The link carries only the code, so the invited email is looked up on load and shown read-only. Username and password follow the Register rules, including PasswordStrengthHint. On success the person is logged in and sent to /home with a toast; they are already verified because the invite went to their email.', states: [
|
|
{ name: 'Checking the link', trigger: 'On load, while the invite is looked up.', spinner: 'Checking your invite…' },
|
|
{ ...inv, name: 'Default', trigger: 'The code is valid and unused.', fields: invF({}, {}) },
|
|
{ ...inv, name: 'Validation errors', trigger: 'Checked on blur and on submit, using the same rules as Register. Nothing is sent.', fields: invF({ value: 'ma', err: 'Use 3 to 20 characters.', countErr: true, help: '' }, { value: 'lime', hints: pwHint('err') }) },
|
|
{ ...inv, name: 'Filled, valid', trigger: 'Every rule is met and the checklist item turns green.', fields: invF({ value: 'lea' }, { value: 'limewash2026', shown: true, hints: pwHint('met') }) },
|
|
{ ...inv, name: 'Submitting', trigger: 'Fields and button are disabled.', fields: invF({ value: 'lea' }, { value: 'limewash2026', hints: pwHint('met') }), submit: { t: 'Creating account…', loading: true } },
|
|
{ ...inv, name: 'Username taken (409)', trigger: 'Another user has that username. The value is kept and the field gets focus.', fields: invF({ value: 'marta', err: 'That username is taken. Try another.', focus: true, help: '' }, { value: 'limewash2026', hints: pwHint('met') }) },
|
|
{ name: 'Invalid or used link (404)', trigger: 'The code is wrong, was already accepted, or is missing from the URL. No form is shown.', result: { tone: 'error', icon: 'link-2-off', title: 'This invite link doesn\'t work', text: 'It may be incomplete, or it may already have been used. If you already set up your account, log in instead.', note: 'Open the link from your email again, or copy the full address into your browser.', actions: [{ t: 'Go to log in' }] } },
|
|
{ name: 'Expired link (410)', trigger: 'The invite is older than 3 days.', result: { tone: 'error', icon: 'clock', title: 'This invite has expired', text: 'Invites work once, for 3 days. Ask the person who invited you to send a new one.', actions: [{ t: 'Go to log in', secondary: true }] } },
|
|
{ ...inv, name: 'Server error', trigger: 'A 500 or no response. The values are kept.', notice: { tone: 'error', icon: 'circle-x', title: 'Couldn\'t create the account', text: 'Something went wrong on the server. Try again in a moment.' }, fields: invF({ value: 'lea' }, { value: 'limewash2026', hints: pwHint('met') }) },
|
|
{ name: 'Success', trigger: '200 sets the session cookie → /home after 800 ms, with a toast. No verification step.', result: { tone: 'success', icon: 'circle-check', title: 'Your account is ready', text: 'Taking you to your dashboard…', actions: [] }, toast: toast('success', 'circle-check', 'Welcome to RenewCMS', 'You\'re logged in as lea.') }
|
|
] },
|
|
{ label: 'Forgot password', title: 'Forgot password', route: '/register/reset/request', api: 'POST /v1/auth/password-reset', note: 'The sent state reads the same whether or not the email exists, so the page doesn\'t reveal which emails have accounts.', states: [
|
|
{ ...reqBase, name: 'Default', trigger: 'From "Forgot password?" on log in.', fields: [E()] },
|
|
{ ...reqBase, name: 'Validation error', trigger: 'Invalid email on submit. Nothing is sent.', fields: [E({ value: 'marta@renew', err: 'Enter a full email address, like name@example.com.' })] },
|
|
{ ...reqBase, name: 'Submitting', trigger: 'Request in flight.', fields: [E({ value: 'marta@renew.example' })], submit: { t: 'Sending…', loading: true } },
|
|
{ name: 'Sent', trigger: 'Any 200, whether or not the account exists.', result: { tone: 'success', icon: 'mail-check', title: 'Check your email', text: 'If an account exists for that email, a password reset link has been sent.', note: 'The link expires in 1 hour.', actions: [{ t: 'Back to log in', secondary: true }] } },
|
|
{ ...reqBase, name: 'Too many attempts (429)', trigger: 'Rate limit with Retry-After.', notice: { tone: 'info', icon: 'timer', title: 'Too many requests', text: 'Wait a moment before asking for another link.' }, fields: [E({ value: 'marta@renew.example' })], submit: { t: 'Try again in 0:58', disabled: true }, pending: true },
|
|
{ ...reqBase, name: 'Server error', trigger: '500 or no response.', notice: { tone: 'error', icon: 'circle-x', title: 'Couldn\'t send the link', text: 'Check your connection and try again.' }, fields: [E({ value: 'marta@renew.example' })] }
|
|
] },
|
|
{ label: 'Reset password', title: 'Choose a new password', route: '/register/reset/validate?c=CODE&email=EMAIL', api: 'POST /v1/auth/password-reset/confirm', note: 'Opened from the reset email. The email comes from the URL and is read-only. The confirm field is checked only in the browser.', states: [
|
|
{ ...rv, name: 'Default', trigger: 'Both parameters are present.', fields: rvF({}, {}, ['unmet', 'unmet']) },
|
|
{ ...rv, name: 'Too short', trigger: 'On submit, with fewer than 8 characters.', fields: rvF({ value: 'lime', err: '' }, { value: 'lime' }, ['err', 'met']) },
|
|
{ ...rv, name: 'Mismatch', trigger: 'On blur of the confirm field, or on submit.', fields: rvF({ value: 'limewash2026' }, { value: 'limewash2025', err: 'The passwords don\'t match.' }, ['met', 'err']) },
|
|
{ ...rv, name: 'Submitting', trigger: 'Request in flight.', fields: rvF({ value: 'limewash2026' }, { value: 'limewash2026' }, ['met', 'met']), submit: { t: 'Changing password…', loading: true } },
|
|
{ ...login, name: 'Success', trigger: '200 → /login?reason=password-changed.', notice: { tone: 'success', icon: 'circle-check', title: 'Password changed', text: 'Log in with your new password.' }, fields: loginF({}, {}) },
|
|
{ name: 'Invalid or expired (403)', trigger: 'The code is wrong, used, or older than 1 hour.', result: expired },
|
|
{ name: 'Missing parameters', trigger: 'The link has no c or no email. No request is made.', result: { tone: 'error', icon: 'link-2-off', title: 'This link is incomplete', text: 'Part of the reset link is missing. Open it from your email again, or request a new one.', actions: [{ t: 'Request a new link' }] } }
|
|
] }
|
|
]
|
|
}
|
|
renderVals() {
|
|
const modes = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']].map(([k, label]) => {
|
|
const on = this.state.mode === k
|
|
return { label, on, bg: on ? 'var(--ui-primary)' : 'transparent', fg: on ? 'var(--ui-bg)' : 'var(--ui-text-toned)', pick: () => this.setState({ mode: k }) }
|
|
})
|
|
return { rootRef: this.rootRef, modes, groups: this.groups() }
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|