# ENG-6426 · Volant identity: sign-in, signup, profile and emails

- **Linear:** no DES issue (design made for the build) · [ENG-6426](https://linear.app/reprecinct/issue/ENG-6426/design-the-volant-sign-in-signup-profile-and-email-mockups) (design) · project [Volant Identity — Own Sign-in, Profiles and Signup Autofill](https://linear.app/reprecinct/project/volant-identity-own-sign-in-profiles-and-signup-autofill-f403bfdd8dc8) (the PRD, FR1–FR46)
- **Status:** Draft
- **Figma:** none. The sign-in pages use the shape of the ElevateIQ sign-in page (`app.elevateiq.com.au/login`), drawn with Volant tokens and components. The password is shared with ElevateIQ and CRM, so the two apps look related.

Open `index.html` first. It links every state and the six prototypes. Each state file is responsive: check it at desktop width and at 390 px.

## Files

### Sign in (`prototype-login.html`)

| File | What it shows |
| --- | --- |
| `login-default.html` | `/login`. Logo top-left, dotted background, a 400 px card: Email, Password with "Forgot password?" in the label row, Sign in, and "New to Volant? Request an account". The note under the card says that the password also works in CRM and ElevateIQ. |
| `login-filled.html` | Email and password typed, focus in the password field, the caps-lock hint on. |
| `login-loading.html` | After Sign in: the button shows a spinner and "Signing in…". The fields keep their values. |
| `login-error.html` | Wrong email or password (FR9): one general message. The email stays; the password is cleared. |
| `login-challenge.html` | After repeated failures: a warning and the Cloudflare Turnstile widget (labelled placeholder) above Sign in (NFR1). |
| `login-rate-limited.html` | The rate limit hit: Sign in is disabled, with the wait time and a path to reset the password. |
| `login-signed-out.html` | The session expired (today `?reason=expired`): an info message above the form. |
| `code-default.html` | The 2FA step (FR5): masked email, six code cells, Verify (disabled until 6 digits), "Send again in 0:24", "Use a different account". The still shows the 30 s countdown 6 s after it started. |
| `code-loading.html` | 6 digits typed: the cells lock and Verify shows "Checking…". |
| `code-error.html` | Wrong code: red cells and an error message. The cells keep the digits so the user can fix one. |
| `code-expired.html` | The code expired: "Send a new code" is a live link. |
| `code-resent.html` | After "Send a new code": a success message, and the 30 s countdown starts again. |
| `code-locked.html` | Too many wrong codes (Auth0 limit): the sign-in stops, with "Sign in again". |
| `no-access.html` | Correct credentials and code, but no Agent or Tenant profile and no invitation (FR8). Two choices: ask your admin, or Request an account. No session. |

### Forgot password (`prototype-forgot.html`)

| File | What it shows |
| --- | --- |
| `forgot-default.html` | "Reset your password": Email and Send link (FR10). |
| `forgot-error.html` | The email is not valid (client check only). |
| `forgot-loading.html` | Sending. |
| `forgot-sent.html` | Always the same message, whether the account exists or not (FR10, NFR2). |
| `reset-default.html` | `/reset-password`: the shared-password note (FR13), New password with the policy checklist, Confirm. |
| `reset-typing.html` | The checklist while the user types: two rules met. |
| `reset-error.html` | A rule missed and the passwords do not match. |
| `reset-loading.html` | Saving. |
| `reset-expired.html` | The link expired or was used (FR15): "Send a new link". |
| `login-reset-success.html` | Back on `/login` with the success message (FR14). |

### Agent signup (`prototype-signup.html`)

| File | What it shows |
| --- | --- |
| `signup-request.html` | The invitation came from an approved account request (FR20). Email and Company are read-only (lock); first name, last name, phone and job title are filled and editable. An info message says where the values came from. |
| `signup-invite.html` | An ordinary invitation (FR21): only Email and Company are filled. Phone and Job title are marked "(optional)". The lede names the inviter. |
| `signup-error.html` | Validation errors: an error summary, first name missing, passwords do not match. |
| `signup-loading.html` | Creating the account. |
| `signup-existing.html` | The email already has a rePrecinct account (FR19): the existing password and "Forgot password?". No new password, and the copy says the password does not change. |
| `signup-code.html` | The code step after signup (FR18). The first code is the proof that the user owns the email. |
| `signup-invalid.html` | Expired, revoked or used invitation (FR25). |

### Tenant in the share viewer (`prototype-share.html`)

The existing modal (`share-auth-dialog`) stays: it cannot be closed, and the share is blurred behind it. It opens directly on the right variant. The share link names the email, so the variant can depend on whether the account exists (NFR2 exception).

| File | What it shows |
| --- | --- |
| `share-signup.html` | New Tenant (FR26, FR27): email read-only, Create a password with the checklist, Confirm. |
| `share-signin.html` | Returning Tenant: email read-only, Password, "Forgot password?". |
| `share-signin-error.html` | Wrong password. |
| `share-existing.html` | The email has a rePrecinct account but no Tenant (FR28): the existing password. |
| `share-code.html` | The code step, the same as `code-default.html` inside the modal. |
| `share-profile.html` | The next step: the existing "Complete your profile" dialog, unchanged (FR29). Shown here only to show where the flow goes. In the prototype, Continue ends the flow: it opens the share (`prototype-tenant-profile.html`). |

### Agent profile (`prototype-profile.html`)

| File | What it shows |
| --- | --- |
| `profile-menu.html` | The profile menu open. The name-and-email header is now a link to `/profile` with a chevron (FR30), shown in its hover state. |
| `profile-menu-focus.html` | The same link with keyboard focus (the focus ring). |
| `profile-loading.html` | `/profile` loading: skeletons. |
| `profile-default.html` | `/profile` (FR31–FR34): Details and Password, each in its own section with its own button. Save details is disabled until a value changes. |
| `profile-editing.html` | A change not saved yet: Save details is live. |
| `profile-saved.html` | After save: a quiet "Saved" next to the button. No toast. |
| `profile-error.html` | The phone number is not valid, and the current password is wrong (FR39). |
| `profile-password-changed.html` | Password changed (FR40): the user stays signed in on this device. |

### Tenant profile (`prototype-tenant-profile.html`)

| File | What it shows |
| --- | --- |
| `tenant-profile-menu.html` | The share-viewer menu: "Edit business details" becomes "My profile" (FR35). The name-and-email header is outside `role="menu"`: it is text, not a menu item. In the Agent menu, the same header is a menu item, because it is the link to `/profile`. |
| `tenant-profile-default.html` | The "My profile" dialog (FR36): first and last name, Email read-only, ABN and ACN, Credit check consent read-only. Password collapsed behind "Change password" (FR37). |
| `tenant-profile-password.html` | Change password open: Current, New with the checklist, Confirm, Cancel and Change password. |
| `tenant-profile-error.html` | The ABN is not valid, and the current password is wrong. |
| `tenant-profile-saved.html` | Details saved. |

### Emails

| File | What it shows |
| --- | --- |
| `email-2fa-code.html` | The 2FA code email (FR41, FR42). It has the same shape as the signing-code email. |
| `email-password-reset.html` | The reset email (FR12). |
| `email-invitation.html` | The new Agent invitation email (FR16, FR17). |
| `email-welcome.html` | Today's welcome email. Check: it already uses the shared layout, so all four emails match. Only the target of its button changes. |

### Other files

| File | What it is |
| --- | --- |
| `index.html` | A link to each state and each prototype. |
| `prototype-*.html`, `prototype.css`, `prototype.js` | Six clickable flows. They share one script and one style sheet. They have live password rules, the show-password toggle, a code field that takes paste and moves on at 6 digits, and the countdown. A bar at the bottom jumps to any screen. They show behaviour and feel only. |
| `identity.css` | Feature CSS. The markup is in the comment at the top. |
| `email.css` | Email mockup CSS. It imitates `emailLayout`. |
| `assets/logo.png` | The Volant logo, a copy of `packages/brand/assets/logo.png`. |

## Where it goes in code

| Mockup part | Code |
| --- | --- |
| Sign-in page shell (logo, dotted background, card) | `apps/web/src/features/auth/pages/login/login.module.css`: replace the gradient card. Share the shell with the new pages (new `features/auth/components/auth-card`). |
| Login form, code step, no-access | `features/auth/pages/login/login.tsx` and new steps on the same route. The failure messages move from `lib/login-failure.ts`. |
| Forgot and reset pages | New routes `/forgot-password` and `/reset-password`. |
| Agent signup | `routes/invite.accept.$token.tsx` and `features/auth/pages/invite-accept/`: it becomes the form. `features/auth/pages/signup/` (the name step) goes. |
| Tenant steps | `features/applications/pages/share-viewer/components/share-auth-dialog.tsx`: the modal gets the steps. `share-tenant-profile-dialog.tsx` is unchanged. |
| Profile menu link | `components/app-shell/profile-menu.tsx`: the `profile-menu__identity` header becomes a `DropdownMenuItem asChild` with a `Link to="/profile"` . Its accessible name comes from the visible name and email, with no `aria-label` (open question 11). |
| `/profile` | New route and `features/profile/` page. |
| Tenant "My profile" dialog | `share-viewer/components/edit-business-details-dialog.tsx` becomes the profile dialog. It reuses `share-business-fields.tsx` for ABN and ACN. `share-business-identifiers-menu-item.tsx` becomes "My profile". |
| Emails | `apps/api/src/core/email/layout.ts` (unchanged). New renderers for reset and invitation next to `render-invitation-email.ts`. The 2FA email is the Auth0 Liquid template (FR42). |
| `data-component="Button"`, `ButtonAnchor`, `Input`, `Field`, `DialogContent`, `DialogTitle`, `DropdownMenu*`, `SelectTrigger`, `Skeleton`, `Spinner`, `Checkbox` | The export of that name in `apps/web/src/components/ui/`. |
| Password field (eye toggle, caps-lock hint, policy checklist) | New. There is no password component in `components/ui`. Proposal: a `PasswordInput` in `components/ui`, built on `Input` and `Field` (see open question 3). |
| Six-cell code field | New feature component. It is **one** `<input autocomplete="one-time-code" inputmode="numeric" maxlength="6">` drawn over six cells. It is not six inputs. |
| Read-only field (muted fill and lock) | `Input` with `readOnly`. The style is new (see open question 2). |

## Behaviour

### All pages

- **Shell:** the logo is at the top left and links to nothing. The card is 400 px wide, and 480 px for signup. On a phone, the card is full width with 16 px gutters and the dotted background stays.
- **Focus order:** the first empty field gets focus on load. On the code step, focus goes to the code field. After an error, focus goes to the alert and then to the first field with an error.
- **Errors:** a field error is under its field (`aria-describedby`, `aria-invalid`). A form error or a warning is an alert at the top of the card (`role="alert"`), because the user must hear it while focus is in a field. A success or an info message uses `role="status"`.
- **Buttons:** one purple primary button per view. While a request runs, the button shows a spinner and a verb in "-ing" form ("Signing in…"), and it is disabled. The fields keep their values.
- **Password fields:**
  - The eye toggle is a `<button>` with the name "Show password" or "Hide password" and `aria-pressed`.
  - The caps-lock hint shows only while caps lock is on and the field has focus.
  - The `autocomplete` is `current-password` or `new-password`, so password managers work.
- **Policy checklist:** the rules are under each new-password field. Each rule turns green with a tick when it is met. After a failed submit, an unmet rule turns red. The rules are the Auth0 policy (open question 1).
- **Copy:** the mockup copy is final unless an open question says otherwise.

### Sign in

1. The user enters email and password, then selects Sign in. The BFF checks them with Auth0 (FR4).
2. If they are wrong, the page shows "The email or password is not correct. Try again, or reset your password." The page never says which part is wrong (FR9).
3. After repeated failures, Turnstile shows above the button. After the rate limit, Sign in is disabled and the message gives the wait time.
4. If they are correct, the code step opens (FR5).
   - The code expires after 5 minutes (open question 4).
   - At 6 digits the form submits. A paste fills all six cells.
   - "Send again" waits 30 s, then becomes a link (FR6).
   - "Use a different account" goes back to the empty login.
5. If the code is correct and the user has a live profile or invitation, the app opens. Otherwise `no-access.html` shows, and there is no session (FR8).

"Request an account" opens `https://volant.lease/#request-account` (FR3) in the same tab.

### Forgot password

The sent page always shows the same text (FR10). The reset page needs a valid token. Without one, it shows `reset-expired.html`. After a reset, `/login` shows the success message, and the user's other sessions end (FR14).

### Agent signup

- The page shows `signup-request` or `signup-invite`, depending on the invitation (FR20, FR21).
- If the email already has an Auth0 user, the page shows `signup-existing` (FR19).
- Create account leads to the code step. The Agent profile is created only after the code passes (FR18).
- No terms checkbox (FR23).

### Tenant

- New Tenant: `share-signup`, then `share-code`, then the existing profile step.
- Returning Tenant: `share-signin`, then `share-code`, then the share.
- Existing rePrecinct user who is not a Tenant: `share-existing`, then `share-code`, then the profile step.
- "Forgot password?" in the modal opens `/forgot-password` with the email filled.

### Agent profile

- The trigger in the sidebar still opens the menu. In the menu, the name-and-email row is a link to `/profile`.
- Details and Password save on their own. Save details is disabled until a value changes.
- The phone uses the Request Account rule: the country select and the number (FR32).
- Change password needs the current password (FR38, FR39). After it, the other sessions end and this one stays (FR40).

### Tenant profile

- The dialog can be closed (X, Esc, outside click). This is different from the sign-in modal.
- The ABN and ACN rules are today's rules in `share-business-fields.tsx`. Consent is read-only. It shows the date it was given.
- Password is collapsed until the user selects "Change password".

### Emails

All four emails use `emailLayout`: the logo on white, the purple heading bar, the body, and the footer "Sent via Volant.Lease".

- **2FA:** the subject is "Your Volant sign-in code". The code is in the body only (open question 5).
- **Reset:** sent only to a live Agent or Tenant (FR11). The link works once, for 60 minutes.
- **Invitation:** the From name stays as in `invitationEmailFromName`. The body names the inviter, and the link works for 7 days (`AGENT_INVITATION_TTL_SEC`).
- **Welcome:** unchanged. Its button opens the filled signup.

## Open questions for the designer

1. **Password policy.** The mockup shows the Auth0 "Fair" policy: 8 characters, a lower-case letter, an upper-case letter and a number. Confirm the level that the shared tenant uses. The checklist must show exactly those rules.
2. **Read-only field style (code wins: none yet).** `Input` has no read-only style today. A disabled input has 55% opacity. The mockup shows read-only values at full contrast, with a muted fill and a lock, so they do not look broken. This needs a change in `input.module.css`.
3. **Password component.** A `PasswordInput` (eye toggle, caps lock hint, policy checklist) is used on eight screens. Do we add it to `components/ui`?
4. **Code timing.** The mockup says the code expires in 5 minutes, and resend waits 30 s. Confirm both with the Auth0 MFA email settings and the resend limits (FR6).
5. **Code in the email subject.** The mockup keeps the code out of the subject. A subject goes to lock-screen notifications, mail gateway and antivirus logs, thread metadata and the sent copy; the body does not. Check on iOS and Android that one-time-code autofill reads the code from the body. Put the code in the subject only as an opt-in, if the team still wants it.
6. **Rate limit wait.** The mockup says "Wait 15 minutes". Use the real value from the rate-limit config.
7. **Invitation errors.** The mockup shows one message for expired, revoked and used invitations. Do we need a different message for each?
8. **App shell at phone width.** The mockup draws a top bar with the avatar on `/profile`. The current sidebar has no phone layout. Use whatever phone shell the app has, or open a ticket.
9. **Changed from today's code (code-wins differences):**
   - `/login` was a centred card on a purple gradient, with the title "Sign In to Continue". The mockup replaces it with the ElevateIQ shape and "Sign in to Volant".
   - `share-auth-dialog` inputs use `font-size-lg`. The mockup uses the standard `Input` size.
   - The invitation email used "Reprecinct" and an Auth0 ticket.
10. **Proposed tokens:** none. Every value is a token or a px size (`design/AGENTS.md`). One exception: the control height `2.25rem` is copied from `button.module.css` and `input.module.css`, so the mockup controls scale like the real ones.
11. **Accessible name of the "My profile" link.** NFR5 says the name is "My profile". The link shows the name and email, and WCAG 2.5.3 (label in name) needs the accessible name to contain the visible text, so the mockup has no `aria-label`. Change NFR5, or add the visible text "My profile" to the link.
