diff --git a/docs/design/README.md b/docs/design/README.md index 09096d3..7dbf756 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -32,7 +32,7 @@ The visual spec of record for the back-office SPA and the transactional emails, | [Integration](screens/integration.md) | `/integration` | 2 | `source/RenewCMS Screens Back-office.dc.html` | | [Users](screens/users.md) | `/users` | 10 | `source/RenewCMS Screens Back-office.dc.html` | | [Profile](screens/profile.md) | `/profile` | 13 | `source/RenewCMS Screens Back-office.dc.html` | -| [Accept invite](screens/accept-invite.md) | `/invite/accept?c=CODE` | not designed | none yet | +| [Accept invite](screens/accept-invite.md) | `/invite/accept?c=CODE` | 9 | `source/RenewCMS Screens Auth.dc.html` | | [Global](screens/global.md) | `any route` | 4 | `source/RenewCMS Screens Back-office.dc.html` | ## Other boards diff --git a/docs/design/components.md b/docs/design/components.md index 168a590..7a95e64 100644 --- a/docs/design/components.md +++ b/docs/design/components.md @@ -24,9 +24,9 @@ | SaveStatus | custom (span + UButton) | state: 'clean'\|'dirty'\|'saving'\|'saved'\|'error'\|'offline', savedAt?: Date | retry | none | Editor | | PublishControl | UButton | online: boolean, dirty: boolean, pending?: string, disabled? | publish, unpublish | none | Editor | | ArticleDetails | UCard (desktop) / USlideover (mobile) | article: Article | copy-url | none | Editor | -| CharacterCounter | custom | value: number, max: number, min?: number, warnAt?: number | none | none | NewArticleModal, Editor, Register | -| PasswordStrengthHint | custom (in UFormField #help) | rules: { label, met }[], showErrors: boolean | none | none | Register, Reset | -| ResultPanel | custom + UButton | tone: 'success'\|'error'\|'info', icon, title, description, actions: ButtonProps[] | none | default | Pending, Verify, Reset request, Reset, Register (403) | +| CharacterCounter | custom | value: number, max: number, min?: number, warnAt?: number | none | none | NewArticleModal, Editor, Register, Accept invite | +| PasswordStrengthHint | custom (in UFormField #help) | rules: { label, met }[], showErrors: boolean | none | none | Register, Reset, Accept invite | +| ResultPanel | custom + UButton | tone: 'success'\|'error'\|'info', icon, title, description, actions: ButtonProps[] | none | default | Pending, Verify, Reset request, Reset, Accept invite, Register (403) | | CodeBlock | custom (shiki) + UButton | code: string, lang: string, label? | none | none | Integration | | EndpointCard | UCard, UBadge, UTabs | method: 'GET', path, description, examples: { fetch, curl, response } | none | none | Integration | | ErrorView | custom + UButton | kind: 'not-found'\|'error' | retry | none | Not found, error boundary | diff --git a/docs/design/routes.md b/docs/design/routes.md index a401f1f..b21ec29 100644 --- a/docs/design/routes.md +++ b/docs/design/routes.md @@ -18,7 +18,7 @@ A global `router.beforeEach` guard reads `meta.auth`. On the first navigation it | `/integration` | DashboardShell | verified | IntegrationPage | none | | `/users` | DashboardShell | verified | UsersPage + InviteUserModal | GET /v1/user/ · POST /v1/user/invite | | `/profile` | DashboardShell | verified | ProfilePage | GET /v1/auth/me · PATCH /v1/user/me · POST /v1/auth/password-reset | -| `/invite/accept` | AuthShell | public | AcceptInvitePage (not designed) | not defined | +| `/invite/accept` | AuthShell | public | AcceptInvitePage | GET /v1/auth/invite?c=* · POST /v1/auth/invite/accept* | | `/:pathMatch(.*)*` | either | any | NotFoundPage | none | Auth values: `public` needs no session, `session` needs a logged-in account (verified or not), `verified` needs a verified account, `any` applies to redirects and the catch-all. diff --git a/docs/design/screens/accept-invite.md b/docs/design/screens/accept-invite.md index dba5357..1bb4023 100644 --- a/docs/design/screens/accept-invite.md +++ b/docs/design/screens/accept-invite.md @@ -1,29 +1,114 @@ # Accept invite -**Not designed yet.** The export has no screen for this route. The invitation email links to it, so it has to exist, but nothing in the boards fixes its layout, fields or copy. Do not implement it from this file. Get it designed first (see the prompt at the end), export it, and replace this file with the real spec. - - **Route:** `/invite/accept?c=CODE` -- **Layout:** AuthShell (public, like Register) -- **API:** not defined. It needs to read the invite behind the code and to create the account from it. +- **API:** GET /v1/auth/invite?c=CODE (pending) · POST /v1/auth/invite/accept (pending) +- **Visual reference:** `source/RenewCMS Screens Auth.dc.html`, group "Accept invite" (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). -## What the rest of the design already fixes +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, with no confirm field. On success the person is logged in and sent to /home with a toast. The account is already verified, because the invite went to their email, so it skips the pending and verify screens. -- The link is `{{.Host}}/invite/accept?c={{.VerificationCode}}`. It carries no email parameter. -- The Invite modal says: "We'll email them a link to choose a username and password. The link expires after 3 days." -- The email says: "Accept to choose a username and password. You'll sign in with your own account." and "This invite was sent to {{.Email}}. It expires in 3 days and works once." -- The email also says: "If the invite has expired, ask {{.InvitedBy}} to send a new one." Nobody else can revive an invite. -- The Users list calls the invite Accepted once the account is created. -- Registration stays closed to everyone else: [register.md](register.md) "Registration closed" (403 "This site already has an owner. Log in instead.") still applies to `/register`. +Neither endpoint exists yet: -## What has to be decided in the design +- `GET /v1/auth/invite?c=CODE` returns `{ email }`, or 404 or 410. +- `POST /v1/auth/invite/accept` takes `{ code, username, password }`. It returns 200 with the session cookie, 409 for a taken username, or 404 or 410 as above. -- Fields: username and password, with the same rules as Register (username 3 to 20, password at least 8, with the PasswordStrengthHint)? Is there a confirm field? Is the invited email shown read-only? -- What the screen does after success: log the user in and go to Home with a toast, or go to the login screen. -- States for a code that is invalid, already used, or expired (3 days), and how each one is worded. -- The submitting, validation, username-taken and server-error states. -- Whether an account created this way is verified straight away. The email address is proven by the link, so it can skip the pending and verify screens. -- Behavior when someone who is already logged in opens the link. +Registration stays closed to everyone else: [register.md](register.md) "Registration closed" still applies to `/register`. The board does not draw the case of a logged-in person opening the link; it is not specified. -## Prompt for Claude Design +## States -> Add an "Accept invite" screen to the Auth board of RenewCMS, at `/invite/accept?c=CODE`, inside AuthShell. It opens from the invitation email (emails/invitation.html). The invited person chooses a username and a password, with the same rules and PasswordStrengthHint as Register. Show the invited email read-only if that reads well. Draw every state at desktop and mobile, in light and dark: default, validation errors, submitting, username already taken, invalid or already-used link, expired link (tell them to ask the person who invited them), and server error. Decide what happens on success (logged in and sent to Home with a toast is my preference) and draw the toast. Reuse the existing Register and Verify components and copy style. Then export the boards and the handoff again. +### Checking the link + +**Trigger:** On load, while the invite is looked up. + +- Full-card spinner: "Checking your invite…" + +### Default + +**Trigger:** The code is valid and unused. + +- Heading: **Accept your invite** +- Description: "Choose a username and password to finish setting up your account." +- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation." +- Field `Username`: placeholder "e.g. lea"; counter "0 / 20"; help "Letters and numbers, 3 to 20 characters. You'll use it to log in." +- Field `Password`: password with show/hide; checklist "At least 8 characters" (unmet) +- Submit button: "Accept invite" +- Footer: Already have an account? [Log in] + +### Validation errors + +**Trigger:** Checked on blur and on submit, using the same rules as Register. Nothing is sent. + +- Heading: **Accept your invite** +- Description: "Choose a username and password to finish setting up your account." +- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation." +- Field `Username`: placeholder "e.g. lea"; value "ma"; counter "2 / 20" in error; **error "Use 3 to 20 characters."** +- Field `Password`: value "lime"; password with show/hide; checklist "At least 8 characters" (err) +- Submit button: "Accept invite" +- Footer: Already have an account? [Log in] + +### Filled, valid + +**Trigger:** Every rule is met and the checklist item turns green. + +- Heading: **Accept your invite** +- Description: "Choose a username and password to finish setting up your account." +- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation." +- Field `Username`: placeholder "e.g. lea"; value "lea"; counter "3 / 20"; help "Letters and numbers, 3 to 20 characters. You'll use it to log in." +- Field `Password`: value "limewash2026"; password with show/hide; password shown; checklist "At least 8 characters" (met) +- Submit button: "Accept invite" +- Footer: Already have an account? [Log in] + +### Submitting + +**Trigger:** Fields and button are disabled. + +- Heading: **Accept your invite** +- Description: "Choose a username and password to finish setting up your account." +- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation." +- Field `Username`: placeholder "e.g. lea"; value "lea"; counter "3 / 20"; help "Letters and numbers, 3 to 20 characters. You'll use it to log in." +- Field `Password`: value "limewash2026"; password with show/hide; checklist "At least 8 characters" (met) +- Submit button: "Creating account…", loading +- Footer: Already have an account? [Log in] + +### Username taken (409) + +**Trigger:** Another user has that username. The value is kept and the field gets focus. + +- Heading: **Accept your invite** +- Description: "Choose a username and password to finish setting up your account." +- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation." +- Field `Username`: placeholder "e.g. lea"; value "marta"; counter "5 / 20"; focused; **error "That username is taken. Try another."** +- Field `Password`: value "limewash2026"; password with show/hide; checklist "At least 8 characters" (met) +- Submit button: "Accept invite" +- Footer: Already have an account? [Log in] + +### Invalid or used link (404) + +**Trigger:** The code is wrong, was already accepted, or is missing from the URL. No form is shown. + +- ResultPanel (error, link-2-off): **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 "Go to log in" (primary) + +### Expired link (410) + +**Trigger:** The invite is older than 3 days. + +- ResultPanel (error, clock): **This invite has expired**; text "Invites work once, for 3 days. Ask the person who invited you to send a new one."; actions "Go to log in" (secondary) + +### Server error + +**Trigger:** A 500 or no response. The values are kept. + +- Alert (error, circle-x): **Couldn't create the account** "Something went wrong on the server. Try again in a moment." +- Heading: **Accept your invite** +- Description: "Choose a username and password to finish setting up your account." +- Field `Email`: placeholder "you@example.com"; value "lea.martin@renew.example"; read-only; help "From your invitation." +- Field `Username`: placeholder "e.g. lea"; value "lea"; counter "3 / 20"; help "Letters and numbers, 3 to 20 characters. You'll use it to log in." +- Field `Password`: value "limewash2026"; password with show/hide; checklist "At least 8 characters" (met) +- Submit button: "Accept invite" +- Footer: Already have an account? [Log in] + +### Success + +**Trigger:** 200 sets the session cookie → /home after 800 ms, with a toast. No verification step. + +- ResultPanel (success, circle-check): **Your account is ready**; text "Taking you to your dashboard…" +- Toast (circle-check): **Welcome to RenewCMS** "You're logged in as lea." diff --git a/docs/design/screens/users.md b/docs/design/screens/users.md index af8bc69..d5325a1 100644 --- a/docs/design/screens/users.md +++ b/docs/design/screens/users.md @@ -121,4 +121,4 @@ The "Today" label makes this state clock-sensitive, so `task visual:check` skips - An accepted user is shown by username and display name only, never by email. - How display names look in this list is the state "Display names in the Users list" of [profile.md](profile.md). The board draws it in the Profile group, and `task visual:check` finds states by group. - 429 on the invite request is not drawn. Decide it in the spec before coding. -- The email the invite sends is specified in [emails.md](../emails.md). The screen the link opens is not designed yet: see [accept-invite.md](accept-invite.md). +- The email the invite sends is specified in [emails.md](../emails.md). The screen the link opens is in [accept-invite.md](accept-invite.md). diff --git a/docs/design/source/RenewCMS Handoff.dc.html b/docs/design/source/RenewCMS Handoff.dc.html index c556735..df1d863 100644 --- a/docs/design/source/RenewCMS Handoff.dc.html +++ b/docs/design/source/RenewCMS Handoff.dc.html @@ -79,13 +79,14 @@ class Component extends DCLogic { { t: 'Foundations', d: 'Tokens, contrast, type, motion, icons', href: 'RenewCMS Foundations.dc.html' }, { t: 'Primitives', d: 'Themed Nuxt UI components', href: 'RenewCMS Components Primitives.dc.html' }, { t: 'Product components', d: '17 RenewCMS components', href: 'RenewCMS Components Product.dc.html' }, - { t: 'Auth screens', d: 'Six public routes, every state', href: 'RenewCMS Screens Auth.dc.html' }, + { t: 'Auth screens', d: 'Seven public routes, every state', href: 'RenewCMS Screens Auth.dc.html' }, { t: 'Back-office screens', d: 'Verified routes and global states', href: 'RenewCMS Screens Back-office.dc.html' }, - { t: 'Emails', d: 'Previews of both templates', href: 'RenewCMS Emails.dc.html' }, + { t: 'Emails', d: 'Previews of the three templates', href: 'RenewCMS Emails.dc.html' }, { t: 'tokens/renewcms.css', d: '@theme, :root, .dark, .article-content', href: 'tokens/renewcms.css' }, { t: 'tokens/ui.config.ts', d: 'Nuxt UI colors, icons, overrides', href: 'tokens/ui.config.ts' }, { t: 'tokens/vite.config.ts', d: 'Vite plugin setup', href: 'tokens/vite.config.ts' }, { t: 'emails/verification.html', d: 'Go html/template', href: 'emails/verification.html' }, + { t: 'emails/invitation.html', d: 'Go html/template', href: 'emails/invitation.html' }, { t: 'emails/password-reset.html', d: 'Go html/template', href: 'emails/password-reset.html' }, { t: 'brand/favicon.svg', d: '16 px grid mark, dark-aware', href: 'brand/favicon.svg' } ] @@ -95,6 +96,7 @@ class Component extends DCLogic { ['/register', 'AuthShell', 'public', 'RegisterPage', 'POST /v1/auth/register'], ['/register/pending', 'AuthShell', 'session', 'PendingPage', 'DELETE /v1/auth/register · POST /v1/auth/logout'], ['/register/validate', 'AuthShell', 'public', 'VerifyPage', 'POST /v1/auth/verify (+ POST /login inline on 401)'], + ['/invite/accept', 'AuthShell', 'public', 'AcceptInvitePage', 'GET /v1/auth/invite?c=* · POST /v1/auth/invite/accept*'], ['/register/reset/request', 'AuthShell', 'public', 'ResetRequestPage', 'POST /v1/auth/password-reset'], ['/register/reset/validate', 'AuthShell', 'public', 'ResetConfirmPage', 'POST /v1/auth/password-reset/confirm'], ['/home', 'DashboardShell', 'verified', 'HomePage', 'GET /v1/article/'], @@ -120,9 +122,9 @@ class Component extends DCLogic { ['SaveStatus', 'custom (span + UButton)', "state: 'clean'|'dirty'|'saving'|'saved'|'error'|'offline', savedAt?: Date", 'retry', 'none', 'Editor'], ['PublishControl', 'UButton', 'online: boolean, dirty: boolean, pending?: string, disabled?', 'publish, unpublish', 'none', 'Editor'], ['ArticleDetails', 'UCard (desktop) / USlideover (mobile)', 'article: Article', 'copy-url', 'none', 'Editor'], - ['CharacterCounter', 'custom', 'value: number, max: number, min?: number, warnAt?: number', 'none', 'none', 'NewArticleModal, Editor, Register'], - ['PasswordStrengthHint', 'custom (in UFormField #help)', 'rules: { label, met }[], showErrors: boolean', 'none', 'none', 'Register, Reset'], - ['ResultPanel', 'custom + UButton', "tone: 'success'|'error'|'info', icon, title, description, actions: ButtonProps[]", 'none', 'default', 'Pending, Verify, Reset request, Reset, Register (403)'], + ['CharacterCounter', 'custom', 'value: number, max: number, min?: number, warnAt?: number', 'none', 'none', 'NewArticleModal, Editor, Register, Accept invite'], + ['PasswordStrengthHint', 'custom (in UFormField #help)', 'rules: { label, met }[], showErrors: boolean', 'none', 'none', 'Register, Reset, Accept invite'], + ['ResultPanel', 'custom + UButton', "tone: 'success'|'error'|'info', icon, title, description, actions: ButtonProps[]", 'none', 'default', 'Pending, Verify, Reset request, Reset, Accept invite, Register (403)'], ['CodeBlock', 'custom (shiki) + UButton', 'code: string, lang: string, label?', 'none', 'none', 'Integration'], ['EndpointCard', 'UCard, UBadge, UTabs', "method: 'GET', path, description, examples: { fetch, curl, response }", 'none', 'none', 'Integration'], ['ErrorView', 'custom + UButton', "kind: 'not-found'|'error'", 'retry', 'none', 'Not found, error boundary'], @@ -136,6 +138,7 @@ class Component extends DCLogic { M('Register', '/register', [['Default', 'Open instance'], ['Validation errors', 'Client rules fail'], ['Submitting', 'In flight'], ['Registration closed', '403'], ['Server error', '500 or network']]), M('Pending', '/register/pending', [['Default', 'After registering (email known)'], ['Email unknown', 'After logging in unverified'], ['Confirm cancel', 'Cancel registration clicked'], ['Cancelling', 'DELETE in flight'], ['Cancelled', '200: /register with toast'], ['Already verified', '403: /home with toast']]), M('Verify', '/register/validate?c=', [['Verifying', 'On load'], ['Success', '200: /home with toast'], ['Invalid or expired', '403'], ['No session', '401: inline log in, then retry'], ['Missing code', 'No c parameter']]), + M('Accept invite', '/invite/accept?c=', [['Checking the link', 'On load: GET invite*'], ['Default', 'Valid, unused code; email read-only'], ['Validation errors', 'Register rules fail'], ['Submitting', 'In flight'], ['Username taken', '409'], ['Invalid or used', '404 or no c parameter'], ['Expired', '410: ask the person who invited you'], ['Server error', '500 or network'], ['Success', '200: logged in, /home with toast']]), M('Forgot password', '/register/reset/request', [['Default', 'Page load'], ['Validation', 'Invalid email'], ['Submitting', 'In flight'], ['Sent', 'Any 200 (neutral copy)'], ['Too many attempts', '429*'], ['Server error', '500 or network']]), M('Reset password', '/register/reset/validate', [['Default', 'c and email present'], ['Too short', 'Under 8 characters'], ['Mismatch', 'Confirm field differs'], ['Submitting', 'In flight'], ['Success', '200: /login?reason=password-changed'], ['Invalid or expired', '403'], ['Missing parameters', 'No c or no email']]), M('Home', '/home', [['Loading', 'List in flight (after 150 ms)'], ['Default', 'List loaded'], ['Empty', 'Zero articles'], ['Error', '500 or network']]), @@ -153,6 +156,9 @@ class Component extends DCLogic { ['403', 'Any back-office call', 'Unverified: goes to /register/pending', 'Redirect'], ['403', 'Verify', '"This verification link doesn\'t work." Offers Cancel registration and start over', 'ResultPanel'], ['403', 'Cancel registration', '"Your account is already verified." Goes to /home', 'Toast'], + ['404', 'Accept invite', '"This invite link doesn\'t work." Offers Go to log in', 'ResultPanel'], + ['409', 'Accept invite', '"That username is taken. Try another."', 'Field'], + ['410', 'Accept invite', '"This invite has expired. Ask the person who invited you to send a new one."', 'ResultPanel'], ['403', 'Reset confirm', '"This reset link has expired." Offers Request a new link', 'ResultPanel'], ['404', 'Editor load', '"This article doesn\'t exist." Offers All articles', 'ErrorView'], ['404', 'Row or editor action', '"That article no longer exists." The row is removed', 'Toast'], @@ -167,6 +173,7 @@ class Component extends DCLogic { const pending = [ { a: 'GET /v1/auth/me → User | 401', d: 'The session check at boot. The HttpOnly cookie can\'t be read from JS. Until this exists, the guard can call GET /v1/article/ and infer: 401 means logged out, but verified and unverified users can\'t be told apart.' }, { a: 'GET /v1/auth/registration → { open }', d: 'Shows "Create the owner account" on /login. Without it, the link is hidden and /register is reachable by URL only.' }, + { a: 'GET /v1/auth/invite?c=CODE → { email } | 404 | 410', d: 'Looks up the invite on load so the email can be shown and dead links are caught before the form. POST /v1/auth/invite/accept { code, username, password } returns 200 with the session cookie, 409 for a taken username, 404 or 410 as above. Neither exists yet.' }, { a: '429 + Retry-After on /login, /password-reset', d: 'Drives the countdown state. The UI falls back to 60 s if the header is missing.' }, { a: 'Serving /static/uploadedImages/*', d: 'The editor and preview are designed as if images load. Until they do, img tags show a broken image.' }, { a: 'Not supported (disabled or omitted)', d: 'Renaming a title (shown read-only, with an explanation in Details), resending the verification email (the pending page explains how to start over), changing the password while logged in, editing the profile or email, listing or deleting individual images, multiple users.' } diff --git a/docs/design/source/RenewCMS Screens Auth.dc.html b/docs/design/source/RenewCMS Screens Auth.dc.html index c141465..03e8b20 100644 --- a/docs/design/source/RenewCMS Screens Auth.dc.html +++ b/docs/design/source/RenewCMS Screens Auth.dc.html @@ -18,7 +18,7 @@
Every state of the six 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 the back-office board, and the emails are on the email board.
+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 the back-office board, and the emails are on the email board.