diff --git a/.planning/phases/02-users-authentication/02-UI-SPEC.md b/.planning/phases/02-users-authentication/02-UI-SPEC.md
new file mode 100644
index 0000000..4b5628d
--- /dev/null
+++ b/.planning/phases/02-users-authentication/02-UI-SPEC.md
@@ -0,0 +1,435 @@
+---
+phase: 2
+slug: users-authentication
+status: draft
+shadcn_initialized: false
+preset: none
+created: 2026-05-22
+---
+
+# Phase 2 — UI Design Contract
+
+> Visual and interaction contract for Phase 2: Users & Authentication.
+> Generated by gsd-ui-researcher, verified by gsd-ui-checker.
+
+---
+
+## Design System
+
+| Property | Value |
+|----------|-------|
+| Tool | none — raw Tailwind CSS v3 |
+| Preset | not applicable |
+| Component library | none |
+| Icon library | Inline SVG heroicons (stroke, no fill) — matches existing AppSidebar and DocumentCard patterns |
+| Font | System font stack (Tailwind default: ui-sans-serif, system-ui, sans-serif) |
+
+**Source:** Detected from `frontend/package.json` (no icon library installed), `frontend/tailwind.config.js` (empty theme.extend), and existing component audit.
+
+---
+
+## Spacing Scale
+
+Declared values (multiples of 4 only):
+
+| Token | Value | Usage |
+|-------|-------|-------|
+| xs | 4px | Icon-to-label gaps, badge internal padding, inline list gaps |
+| sm | 8px | Input icon padding, compact inline spacing |
+| md | 16px | Default field padding, card internal padding, form row gap |
+| lg | 24px | Section vertical gap, card-to-card gap |
+| xl | 32px | Form block top margin, admin table row height padding |
+| 2xl | 48px | Auth page vertical centering padding |
+| 3xl | 64px | Auth card max-height clearance from viewport edge |
+
+Exceptions:
+- Touch targets (buttons, checkboxes, radio): minimum 44px height — enforced via `min-h-[44px]` on all interactive controls
+- TOTP code input cells: 48px wide × 56px tall per digit cell (6 cells)
+- Backup code grid cells: 8px horizontal padding, 12px vertical padding (between xs and sm)
+
+---
+
+## Typography
+
+| Role | Size | Weight | Line Height | Tailwind Class |
+|------|------|--------|-------------|----------------|
+| Body | 14px | 400 (regular) | 1.5 | `text-sm` |
+| Label | 14px | 500 (medium) | 1.4 | `text-sm font-medium` |
+| Heading | 20px | 600 (semibold) | 1.3 | `text-xl font-semibold` |
+| Display | 24px | 700 (bold) | 1.2 | `text-2xl font-bold` |
+
+**Source:** Extracted from `HomeView.vue` (`text-2xl font-bold` page heading, `text-sm` body, `text-lg font-semibold` section headings) and `DocumentCard.vue` (`font-medium text-sm`). Heading reduced from text-lg to text-xl for auth page section hierarchy.
+
+Font weight scale: exactly 2 weights in components (regular 400 + semibold/bold 600/700). Labels use 500 (medium) as the only addition for form ergonomics.
+
+---
+
+## Color
+
+| Role | Value | Tailwind Token | Usage |
+|------|-------|----------------|-------|
+| Dominant (60%) | #f9fafb | `bg-gray-50` | Page background — all views |
+| Secondary (30%) | #ffffff | `bg-white` | Auth cards, form panels, admin table rows, sidebar |
+| Accent (10%) | #4f46e5 | `indigo-600` / `indigo-700` | Reserved for: primary CTA buttons only, active nav link text, brand logo text |
+| Accent subtle | #eef2ff | `indigo-50` | Active nav link background, icon container backgrounds |
+| Destructive | #dc2626 | `red-600` | Deactivate user action, sign-out-all confirmation button, field validation errors |
+| Warning | #d97706 | `amber-600` | Password strength: weak/medium indicator only |
+| Success | #16a34a | `green-600` | Password strength: strong indicator, TOTP enrollment success |
+| Neutral border | #e5e7eb | `gray-200` | Card borders, input default border, table dividers |
+| Muted text | #9ca3af | `gray-400` | Helper text, placeholder text, secondary metadata |
+
+**Source:** `style.css` (body bg-gray-50 text-gray-900), `AppSidebar.vue` (indigo-600 brand, indigo-50 active bg, indigo-700 active text), `DocumentCard.vue` (gray-200 border, hover:indigo-300). Destructive and warning added for auth-specific needs.
+
+Accent reserved for: primary CTA buttons, active sidebar nav link text, DocuVault brand/logo text.
+Accent is NOT used on: input focus rings (use `ring-indigo-500` only), links within prose, or secondary actions.
+
+Input focus ring: `focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none`
+
+---
+
+## Form Field States
+
+All text inputs (``) follow this contract:
+
+| State | Border | Background | Ring | Text |
+|-------|--------|------------|------|------|
+| Default | `border-gray-300` | `bg-white` | none | `text-gray-900` |
+| Focused | `border-indigo-500` | `bg-white` | `ring-2 ring-indigo-500` | `text-gray-900` |
+| Error | `border-red-500` | `bg-white` | `ring-2 ring-red-500` | `text-gray-900` |
+| Disabled | `border-gray-200` | `bg-gray-50` | none | `text-gray-400` |
+| Read-only | `border-gray-200` | `bg-gray-50` | none | `text-gray-700` |
+
+Base classes (all states share): `block w-full rounded-lg px-3 py-2.5 text-sm transition-colors`
+
+Error messages appear **inline**, directly below the field, never as a toast. Class: `mt-1 text-xs text-red-600`. Maximum 1 error line per field.
+
+---
+
+## Password Strength Indicator
+
+Displayed below the password input on Register and Password Reset (new password) screens only.
+
+Visual contract:
+- 4-segment horizontal bar, full width of the input
+- Each segment: 4px tall, rounded, `gap-1` between segments
+- Segments light up left-to-right based on score
+
+| Score | Segments lit | Bar color | Label |
+|-------|-------------|-----------|-------|
+| 0 (empty) | 0 | — | hidden |
+| 1 (too short / fails rules) | 1 | `bg-red-500` | "Too weak" |
+| 2 (fails 2+ rules) | 2 | `bg-amber-500` | "Weak" |
+| 3 (fails 1 rule) | 3 | `bg-amber-400` | "Fair" |
+| 4 (all rules pass) | 4 | `bg-green-500` | "Strong" |
+
+Rules checked client-side (≥12 chars, uppercase, lowercase, digit, special char — matches AUTH-01):
+- Label: `text-xs font-medium` at same color as the bar, right-aligned
+- Unlit segments: `bg-gray-200`
+- Layout: `mt-2 space-y-1`
+
+HaveIBeenPwned breach check runs on blur (not on every keystroke). If the password is pwned, an inline error appears below the strength bar: "This password has appeared in a data breach. Choose a different password." — same `text-xs text-red-600` style. The strength bar is NOT cleared; both messages coexist.
+
+---
+
+## TOTP Enrollment Flow
+
+**Step 1 — QR Code Display**
+
+- QR code: 200px × 200px centered in a white card with `border border-gray-200 rounded-xl p-6`
+- Manual secret: `font-mono text-sm text-gray-700 bg-gray-50 px-3 py-2 rounded-md border border-gray-200 break-all`
+- Copy secret button: icon-only (clipboard SVG, w-4 h-4), `text-gray-400 hover:text-gray-600`, positioned inline right of the secret block
+- Instruction copy: "Open your authenticator app and scan this QR code, or enter the key manually."
+
+**Step 2 — Code Verification**
+
+- Single 6-digit code input: rendered as one `` field, NOT 6 separate cells
+- Width: `w-36` (144px), centered
+- Error state on wrong code: field switches to error state + inline message "Incorrect code. Try again." — generic, no hint about time window
+- On success: green checkmark icon (w-5 h-5 text-green-600) + "Authenticator connected." transition before proceeding to backup codes
+
+**Step 3 — Backup Codes Display**
+
+Layout: 2-column grid of codes (`grid grid-cols-2 gap-2`)
+
+Each code cell:
+- `font-mono text-sm text-gray-800`
+- `bg-gray-50 border border-gray-200 rounded-md px-3 py-2 text-center`
+- Do not number them (no 1., 2. prefix — prevents users from thinking order matters)
+
+Copy-all button: `"Copy all codes"` — text button with clipboard icon, `text-indigo-600 hover:text-indigo-700 text-sm font-medium`. On click: copies newline-separated codes to clipboard, button text changes to `"Copied"` for 2 seconds.
+
+Acknowledgment checkbox (required before proceeding):
+- Checkbox + label: `"I have saved these codes in a secure place. I understand they will not be shown again."`
+- Checkbox class: `rounded border-gray-300 text-indigo-600 focus:ring-indigo-500`
+- "Enable two-factor authentication" CTA button: disabled (`opacity-50 cursor-not-allowed`) until checkbox is checked
+
+---
+
+## Login Flow — TOTP Step
+
+Login is a **two-step flow**, not a single form:
+
+**Step 1:** Email + password fields + "Sign in" button
+**Step 2 (only for TOTP-enrolled users):** Separate screen/card with:
+
+- Heading: "Two-factor authentication"
+- Helper: "Enter the 6-digit code from your authenticator app."
+- Single code input (same spec as enrollment verification above)
+- Secondary link below: "Use a backup code instead" — `text-sm text-indigo-600 hover:underline`
+- Back link: "Back to sign in" — `text-sm text-gray-500 hover:text-gray-700`
+
+Backup code entry: replaces the code input with `` and label "Enter a backup code". Same error state on invalid/already-used code, generic message: "Invalid or already used code."
+
+The step transition (password accepted → TOTP screen) is NOT a page navigation. The card content swaps in place (v-if swap). URL stays at `/login`.
+
+---
+
+## Loading States
+
+All async actions must show a loading state. Contract:
+
+| Action | Loading indicator | Button state |
+|--------|------------------|--------------|
+| Login submit | Spinner (w-4 h-4, inline left of button label) | `disabled`, `opacity-75` |
+| Register submit | Spinner inline | `disabled`, `opacity-75` |
+| HaveIBeenPwned breach check | Spinner (w-3 h-3) right-aligned inside input field | Input remains editable |
+| Password reset email send | Spinner inline | `disabled`, `opacity-75` |
+| TOTP code verify | Spinner inline | `disabled`, `opacity-75` |
+| Admin: create user | Spinner inline | `disabled`, `opacity-75` |
+| Admin: deactivate user | Row-level spinner in action column | Row actions `pointer-events-none` |
+| Admin: reset password | Row-level spinner | Row actions `pointer-events-none` |
+| Admin: save quota | Spinner inline in edit cell | Save button `disabled` |
+| Sign-out-all | Spinner inline | `disabled`, `opacity-75` |
+
+Spinner: `animate-spin rounded-full border-2 border-current border-t-transparent` — inherits button text color. No external spinner library.
+
+---
+
+## Error Message Placement
+
+| Error type | Placement | Style |
+|------------|-----------|-------|
+| Field validation (required, format, strength) | Inline, below field | `mt-1 text-xs text-red-600` |
+| Server field error (email taken, invalid creds) | Inline, below affected field | `mt-1 text-xs text-red-600` |
+| Form-level server error (generic 500, network) | Above the submit button, inside the card | `p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700` |
+| Session expired (401 on any page) | Toast, top-right, 5s auto-dismiss | `bg-gray-900 text-white text-sm px-4 py-3 rounded-lg shadow-lg` |
+| Rate limit hit | Inline form-level block | Same as form-level server error, with copy "Too many attempts. Try again in X minutes." |
+| Success (password reset email sent) | Inline, replaces form | `p-4 bg-green-50 border border-green-200 text-sm text-green-800 rounded-lg` |
+
+**Security copy rule:** Error messages MUST NOT confirm whether an email address exists in the system. Login failure: "Incorrect email or password." Password reset: "If an account exists for that email, you will receive a reset link shortly." — identical response for existing and non-existing emails (anti-enumeration).
+
+---
+
+## Auth Page Layout
+
+Auth pages (`/login`, `/register`, `/reset-password`) share a single-column centered layout:
+
+- Page background: `bg-gray-50 min-h-screen flex items-center justify-center`
+- Card: `bg-white rounded-2xl shadow-sm border border-gray-200 p-8 w-full max-w-sm`
+- Logo/brand above card: `text-lg font-bold text-indigo-600 tracking-tight` centered, `mb-6`
+- Card heading: `text-2xl font-bold text-gray-900 mb-1`
+- Card subheading: `text-sm text-gray-500 mb-6`
+- No sidebar on auth pages — full-width layout
+
+Auth pages are NOT wrapped in the main `App.vue` sidebar layout. They use a bare layout with no navigation.
+
+---
+
+## Account View (/account)
+
+Uses the standard sidebar + main content layout (same as HomeView).
+
+Sections (rendered as stacked cards with `space-y-6`):
+
+1. **Account information** — display-only: email, role badge
+2. **Two-factor authentication** — TOTP status + enrollment/disable controls
+3. **Change password** — current password + new password + strength indicator
+4. **Sessions** — "Sign out all devices" with confirmation dialog
+
+Role badge: `inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-700` for admin; `bg-gray-100 text-gray-600` for user.
+
+**Sign-out-all confirmation dialog:**
+
+- Not a browser `confirm()`. Rendered as an inline confirmation block that replaces the button on click.
+- Copy: "This will sign you out of all devices, including this one. You will need to sign in again."
+- Two buttons: "Cancel" (`text-gray-600 hover:text-gray-800 text-sm`) + "Sign out all devices" (`bg-red-600 hover:bg-red-700 text-white text-sm font-medium px-4 py-2 rounded-lg`)
+- After confirmation: loading state on the destructive button, then redirect to `/login`
+
+---
+
+## Admin View (/admin)
+
+Uses standard sidebar layout. Admin link in `AppSidebar.vue` visible only when `useAuthStore().user?.role === 'admin'`. Admin nav link styled identically to other nav links (no special styling — admin access is not advertised).
+
+**Sub-navigation (within AdminView):**
+
+Horizontal tab strip below the page heading:
+- Tab class (default): `px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 border-b-2 border-transparent`
+- Tab class (active): `px-4 py-2 text-sm font-medium text-indigo-600 border-b-2 border-indigo-600`
+- Strip: `flex border-b border-gray-200 mb-6`
+- Tabs: "Users" | "Quotas" | "AI Config"
+
+**Users Tab — Table:**
+
+Table structure: full-width, `divide-y divide-gray-200`, inside a `bg-white rounded-xl border border-gray-200 overflow-hidden`
+
+Columns: Email | Role | Status | Created | Actions
+
+| Row state | Background | Status badge |
+|-----------|------------|--------------|
+| Active user | `bg-white` | `bg-green-100 text-green-700` "Active" |
+| Deactivated user | `bg-gray-50` | `bg-gray-100 text-gray-500` "Deactivated" |
+| Admin user (active) | `bg-white` | Same as active + role badge |
+
+Row text: `text-sm text-gray-900` for email, `text-sm text-gray-500` for created date.
+
+Actions column (per row): text links, separated by `·`
+- Active row: "Reset password" · "Deactivate"
+- Deactivated row: "Reactivate" (no reset password on deactivated accounts)
+- Deactivate link: `text-red-600 hover:text-red-700 text-sm`
+- Other action links: `text-indigo-600 hover:text-indigo-700 text-sm`
+
+**Create User (Users tab):**
+
+"Create user" button: positioned top-right of table, `bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-4 py-2 rounded-lg`.
+
+Create user form renders as an inline panel above the table (not a modal), with: email field, temporary password field (pre-generated, read-only with copy button), role selector (dropdown: User / Admin).
+
+**Quotas Tab — Inline Edit:**
+
+Table columns: Email | Used | Limit | Usage % | Actions
+
+Limit field is editable inline. Click "Edit" → limit cell becomes `` (in MB) with Save / Cancel buttons. Warning shown inline if new limit < current usage: `text-xs text-amber-600 mt-1 "New limit is below current usage (X MB). Existing documents will not be deleted, but uploads will be blocked."`
+
+**AI Config Tab:**
+
+Table columns: Email | AI Provider | AI Model | Actions
+
+Provider and model: dropdown selectors (not free-text). Populated from the backend's supported provider list. "Save" button per row. No bulk edit.
+
+---
+
+## Sidebar Updates (Phase 2)
+
+`AppSidebar.vue` gains two additions:
+
+1. **Admin link** (bottom section, above Settings): conditionally rendered, `v-if="authStore.user?.role === 'admin'"` — shield SVG icon (w-4 h-4), label "Admin". Same nav-link styling.
+
+2. **User identity footer**: below the settings link at very bottom.
+ - Layout: `flex items-center gap-3 px-4 py-3 border-t border-gray-100`
+ - Avatar: initials-based circle, 32px, `bg-indigo-100 text-indigo-700 text-xs font-semibold rounded-full w-8 h-8 flex items-center justify-center shrink-0`
+ - Email: `text-xs text-gray-600 truncate flex-1`
+ - Sign-out icon button: arrow-right-on-rectangle SVG (w-4 h-4), `text-gray-400 hover:text-gray-600`
+
+---
+
+## Router Guard Feedback
+
+When an unauthenticated user is redirected to `/login` from a protected route, the login card shows no banner by default. The router saves the `redirect` query param (`/login?redirect=/account`). After successful login, the router navigates to the saved route.
+
+If the redirect was triggered by a 401 (token expired, not initial page load), show the session-expired toast (defined in Error Message Placement above) before the redirect completes.
+
+---
+
+## Copywriting Contract
+
+| Element | Copy |
+|---------|------|
+| Register page heading | "Create your account" |
+| Register page subheading | "Start managing your documents securely." |
+| Register primary CTA | "Create account" |
+| Login page heading | "Sign in to DocuVault" |
+| Login page subheading | (none) |
+| Login primary CTA | "Sign in" |
+| Login: redirect to register | "Don't have an account? Create one" |
+| Register: redirect to login | "Already have an account? Sign in" |
+| Forgot password link | "Forgot your password?" |
+| Password reset page heading | "Reset your password" |
+| Password reset subheading | "Enter your email and we'll send you a reset link." |
+| Password reset CTA | "Send reset link" |
+| Password reset success | "If an account exists for that email, you will receive a reset link shortly. Check your inbox." |
+| New password page heading | "Set a new password" |
+| New password CTA | "Set password" |
+| New password success copy | "Password updated. Please sign in." (redirects to /login, not auto-login — per AUTH-05) |
+| TOTP step heading | "Two-factor authentication" |
+| TOTP step helper | "Enter the 6-digit code from your authenticator app." |
+| TOTP verify CTA | "Verify" |
+| TOTP backup code link | "Use a backup code instead" |
+| TOTP enrollment heading | "Set up two-factor authentication" |
+| TOTP backup codes heading | "Save your backup codes" |
+| Backup codes subheading | "Store these codes somewhere safe. Each can only be used once if you lose access to your authenticator app." |
+| Backup codes copy-all CTA | "Copy all codes" |
+| Backup codes acknowledge checkbox | "I have saved these codes in a secure place. I understand they will not be shown again." |
+| TOTP enable CTA | "Enable two-factor authentication" |
+| Account page heading | "Account settings" |
+| Sign-out-all label | "Sign out all devices" |
+| Sign-out-all confirmation copy | "This will sign you out of all devices, including this one. You will need to sign in again." |
+| Sign-out-all destructive CTA | "Sign out all devices" |
+| Admin page heading | "Admin panel" |
+| Admin: create user CTA | "Create user" |
+| Admin: deactivate action | "Deactivate" |
+| Admin: reactivate action | "Reactivate" |
+| Admin: reset password action | "Reset password" |
+| Admin: deactivate confirmation | Inline: "Deactivate [email]? They will lose access immediately. Their data is preserved." + "Deactivate" / "Cancel" |
+| Empty state (users table) | Heading: "No users yet" / Body: "Create the first user account to get started." |
+| Generic server error | "Something went wrong. Please try again or contact support if the problem persists." |
+| Login failure | "Incorrect email or password." |
+| Rate limit error | "Too many attempts. Please wait a few minutes and try again." |
+| TOTP wrong code | "Incorrect code. Try again." |
+| Backup code invalid | "Invalid or already used code." |
+| Password breach error | "This password has appeared in a data breach. Choose a different password." |
+| Password too weak | "Password must be at least 12 characters and include uppercase, lowercase, a number, and a special character." |
+
+**Security copy rules:**
+- Never confirm or deny whether an email exists (password reset and login)
+- Never include user email or PII in generic error messages
+- Never state the number of remaining TOTP attempts
+- Admin actions on user rows include the email in the confirmation to prevent mis-clicks, but error responses from the server must not echo credentials back
+
+---
+
+## Registry Safety
+
+| Registry | Blocks Used | Safety Gate |
+|----------|-------------|-------------|
+| shadcn official | none | not applicable — shadcn not installed |
+| third-party | none | not applicable |
+
+No third-party component registries. All components are handwritten Vue 3 SFC with raw Tailwind CSS, consistent with the existing codebase pattern.
+
+---
+
+## Component Inventory (New — Phase 2)
+
+Components to create, with their file paths:
+
+| Component | Path | Description |
+|-----------|------|-------------|
+| LoginView | `src/views/auth/LoginView.vue` | Two-step login (password → TOTP) |
+| RegisterView | `src/views/auth/RegisterView.vue` | Registration with strength indicator |
+| PasswordResetView | `src/views/auth/PasswordResetView.vue` | Reset request form + success state |
+| NewPasswordView | `src/views/auth/NewPasswordView.vue` | Token-gated new password form |
+| AccountView | `src/views/AccountView.vue` | Account settings, TOTP, sign-out-all |
+| AdminView | `src/views/AdminView.vue` | Admin panel with sub-navigation |
+| AuthLayout | `src/layouts/AuthLayout.vue` | Bare centered layout (no sidebar) for auth pages |
+| PasswordStrengthBar | `src/components/auth/PasswordStrengthBar.vue` | 4-segment strength indicator |
+| TotpEnrollment | `src/components/auth/TotpEnrollment.vue` | QR + manual secret + code verify |
+| BackupCodesDisplay | `src/components/auth/BackupCodesDisplay.vue` | Grid + copy-all + acknowledge checkbox |
+| AppSpinner | `src/components/ui/AppSpinner.vue` | Inline CSS spinner, inherits color |
+| ConfirmBlock | `src/components/ui/ConfirmBlock.vue` | Inline confirm/cancel replacement for destructive actions |
+| AdminUsersTab | `src/components/admin/AdminUsersTab.vue` | User table + create user form |
+| AdminQuotasTab | `src/components/admin/AdminQuotasTab.vue` | Quota inline edit table |
+| AdminAiConfigTab | `src/components/admin/AdminAiConfigTab.vue` | AI provider/model dropdowns per user |
+
+---
+
+## Checker Sign-Off
+
+- [ ] Dimension 1 Copywriting: PASS
+- [ ] Dimension 2 Visuals: PASS
+- [ ] Dimension 3 Color: PASS
+- [ ] Dimension 4 Typography: PASS
+- [ ] Dimension 5 Spacing: PASS
+- [ ] Dimension 6 Registry Safety: PASS
+
+**Approval:** pending