chore: merge executor worktree (worktree-agent-a71adf90ed21b044c)
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
---
|
||||
phase: 19-local-auth-no-oidc-mode
|
||||
plan: "04"
|
||||
subsystem: pwa-auth-ui
|
||||
status: complete
|
||||
tags: [pwa, auth, login-ui, admin, settings, local-auth]
|
||||
requirements_covered: [AUTH-LOCAL-12, AUTH-LOCAL-13, AUTH-LOCAL-14, AUTH-LOCAL-15]
|
||||
|
||||
dependency_graph:
|
||||
requires:
|
||||
- 19-02 (API /api/auth/mode, /api/auth/local/login, /api/admin/members, /api/me/password)
|
||||
- 19-03 (localAuthMiddleware, session cookie, /api/auth/local/logout)
|
||||
provides:
|
||||
- LoginPage (Surfaces 1-10): standalone /login route with brand slot, form, error states, OIDC button
|
||||
- App.tsx auth-mode gate: routes unauthenticated local users to /login; OIDC-only to /api/login
|
||||
- AdminPage LOCAL ACCOUNTS: add-member form (Surface 11A), per-member reset-password sheet (Surface 11B)
|
||||
- SettingsSheet: change-password row + sheet (Surface 12), link-OIDC row + confirmation sheet (Surface 13)
|
||||
- BrandSlot component + brand-seam CSS tokens for Phase 17 override seam
|
||||
affects:
|
||||
- apps/pwa/src/api/client.ts (LoginError, fetchAuthMode, fetchLocalLogin, fetchLocalLogout, fetchChangePassword, fetchCreateMember, fetchAdminResetPassword, fetchLinkOidc, hasLocalCredential on MeUser/AdminMember)
|
||||
- apps/pwa/src/App.tsx (authModeQuery + auth gate + /login route)
|
||||
- apps/pwa/src/routes/AdminPage.tsx (LOCAL ACCOUNTS section, ResetPasswordSheet)
|
||||
- apps/pwa/src/components/SettingsSheet.tsx (change-password + link-OIDC rows + sub-sheets)
|
||||
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- LoginError typed class (mirrors SessionExpiredError; code union 'invalid'|'rate-limit'|'locked'|'server')
|
||||
- BrandSlot component with CSS custom property seam for Phase 17 brand override
|
||||
- fetchLocalLogin maps HTTP status codes to LoginError codes before surfacing to UI
|
||||
- authModeQuery in App.tsx gates /login redirect and OidcRedirect rendering
|
||||
- InstructionSheet.test.tsx wrapped in QueryClientProvider (Rule 1 fix: SettingsSheet now uses useQuery)
|
||||
|
||||
key_files:
|
||||
created:
|
||||
- apps/pwa/src/components/BrandSlot.tsx
|
||||
- apps/pwa/src/routes/LoginPage.tsx
|
||||
modified:
|
||||
- apps/pwa/src/api/client.ts
|
||||
- apps/pwa/src/styles/tokens.css
|
||||
- apps/pwa/src/App.tsx
|
||||
- apps/pwa/src/routes/AdminPage.tsx
|
||||
- apps/pwa/src/components/SettingsSheet.tsx
|
||||
- apps/pwa/src/components/InstructionSheet.test.tsx
|
||||
- apps/pwa/src/App.test.tsx
|
||||
|
||||
decisions:
|
||||
- "OidcRedirect rendered as a React element (not a useEffect) to avoid render-inside-render conflict; window.location.replace in render body is safe for a top-level redirect-only component"
|
||||
- "SettingsSheet reads meQuery(['me']) and authModeQuery(['authMode']) with same keys as App.tsx; TanStack deduplicates the requests — no prop drilling needed"
|
||||
- "ResetPasswordSheet inlined in AdminPage.tsx rather than extracted to separate file; component is only used in one place and matches CredentialSheet locality pattern"
|
||||
- "fetchCreateMember throws HTTP error message so onError can detect '409' string for username-taken copy"
|
||||
|
||||
metrics:
|
||||
duration: "~90 min (continued from previous session)"
|
||||
completed: "2026-06-17"
|
||||
tasks_completed: 3
|
||||
files_modified: 8
|
||||
files_created: 2
|
||||
tests_added: 0
|
||||
tests_modified: 2
|
||||
test_suite_result: "263 tests passed (0 failed)"
|
||||
---
|
||||
|
||||
# Phase 19 Plan 04: PWA Login UI + Account Management Surfaces Summary
|
||||
|
||||
PWA-side login UI built: standalone LoginPage with brand slot + form + 4 error states + optional OIDC button (Surfaces 1-10); App.tsx auth-mode gate added; AdminPage LOCAL ACCOUNTS section with add-member form and per-member reset-password sheet (Surfaces 11A/11B); SettingsSheet change-password and link-OIDC rows with nested bottom sheets (Surfaces 12/13).
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 | client.ts fetch fns + LoginError + BrandSlot + tokens | `869cdc2` | client.ts, BrandSlot.tsx, tokens.css |
|
||||
| 2 | LoginPage (Surfaces 1-10) + App.tsx gate + /login route | `32d0408` | LoginPage.tsx, App.tsx, App.test.tsx |
|
||||
| 3 | AdminPage LOCAL ACCOUNTS + SettingsSheet surfaces 12/13 | `19c45eb` | AdminPage.tsx, SettingsSheet.tsx, InstructionSheet.test.tsx, client.ts, App.test.tsx |
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Task 1: client.ts + BrandSlot + tokens.css
|
||||
|
||||
**client.ts additions:**
|
||||
|
||||
- `class LoginError extends Error` with `readonly code: 'invalid' | 'rate-limit' | 'locked' | 'server'` — mirrors `SessionExpiredError` pattern including `Object.setPrototypeOf` fix
|
||||
- `hasLocalCredential: boolean` added to `MeUser` interface
|
||||
- `hasLocalCredential: boolean` added to `AdminMember` interface
|
||||
- `fetchAuthMode()` — plain GET /api/auth/mode, no credentials; returns `{ localEnabled, oidcEnabled }`
|
||||
- `fetchLocalLogin({ username, password })` — POST /api/auth/local/login with credentials:'include', redirect:'manual'; maps 401 to LoginError('invalid'), 429 to LoginError('rate-limit'), 423 to LoginError('locked'), non-ok to LoginError('server')
|
||||
- `fetchLocalLogout()` — POST /api/auth/local/logout
|
||||
- `fetchChangePassword({ currentPassword, newPassword })` — POST /api/me/password
|
||||
- `fetchCreateMember({ displayName, username, password })` — POST /api/admin/members
|
||||
- `fetchAdminResetPassword(memberId, newPassword)` — POST /api/admin/members/:id/reset-password
|
||||
- `fetchLinkOidc()` — POST /api/me/link-oidc; returns `{ redirectUrl: string }`
|
||||
|
||||
**BrandSlot.tsx:** Phase-17-ready placeholder component. 48px circle with CSS custom properties (`--brand-logo-bg`, `--brand-logo-text`, `--brand-logo-size`, `--brand-logo-border-radius`). "FS" initials. h1 "FamilySync" (24px/600). Tagline "Family calendar & lists" (15px/400, secondary). No img tag. No dangerouslySetInnerHTML. No "Authelia".
|
||||
|
||||
**tokens.css:** Brand-seam block added under `:root`: `--brand-logo-bg`, `--brand-logo-text`, `--brand-logo-size`, `--brand-logo-border-radius`, `--brand-app-name`. Phase 17 overrides these.
|
||||
|
||||
### Task 2: LoginPage + App.tsx gate
|
||||
|
||||
**LoginPage.tsx (465 lines):**
|
||||
|
||||
- Standalone full-page route (same pattern as SetupPage — no AppNav/BottomTabBar)
|
||||
- Accepts `authMode?: { localEnabled: boolean; oidcEnabled: boolean }` prop
|
||||
- Style: 400px max-width column, inline CSSProperties throughout (no shadcn)
|
||||
- Surface 1: BrandSlot at top
|
||||
- Surface 4: username field (type="text", autoFocus, autoComplete="username", spellCheck=false, autoCapitalize="none")
|
||||
- Surface 5: password field with show/hide toggle (Eye/EyeOff); onBlur resets to hidden
|
||||
- Surface 6 error states: invalid / rate-limit / locked / server — distinct copy per UI-SPEC
|
||||
- Surface 7: "Sign in" button (Loader2 spinner while pending); disabled on empty fields, rate-limit, locked
|
||||
- Surface 8: "or" divider (shown when oidcEnabled)
|
||||
- Surface 9: "Login with OIDC" button with ShieldCheck icon (shown when oidcEnabled)
|
||||
- Surface 10: "Forgot your password? Ask your admin." — non-interactive p tag
|
||||
- Focus management: autoFocus on username, useEffect focuses error heading on error change, Enter in username navigates to password field, Enter in password submits
|
||||
- Security: no "Authelia", no dangerouslySetInnerHTML, no field-level blame, password only in controlled state
|
||||
|
||||
**App.tsx additions:**
|
||||
|
||||
- `OidcRedirect` helper component: `window.location.replace('/api/login')` in render body
|
||||
- `authModeQuery` with `fetchAuthMode`, `staleTime: 60_000`
|
||||
- Auth gate in `*` route: `meQuery.isError + localEnabled` navigates to /login; `meQuery.isError + !localEnabled + oidcEnabled` renders OidcRedirect
|
||||
- `/login` route as standalone sibling of `/setup`
|
||||
|
||||
### Task 3: AdminPage LOCAL ACCOUNTS + SettingsSheet surfaces 12/13
|
||||
|
||||
**AdminPage LOCAL ACCOUNTS section:**
|
||||
|
||||
- Surface 11A — "Add member" inline form: display name, username, initial password, confirm password; `useMutation(fetchCreateMember)`; client-side mismatch/short validation + server-side 409 username-taken detection; success clears form + invalidates `['admin', 'members']` and `['me']`
|
||||
- Surface 11B — "Reset password" button in MemberRow: conditioned on `member.hasLocalCredential`; captures trigger button ref for focus-return; opens ResetPasswordSheet
|
||||
- `ResetPasswordSheet` component: bottom sheet (role=dialog, aria-modal, Escape closes, focus heading on open); new password + confirm fields; admin-reset mutation; focus returns to trigger on close
|
||||
|
||||
**SettingsSheet additions:**
|
||||
|
||||
- `useQuery(['me'])` and `useQuery(['authMode'])` inside SettingsSheet — TanStack deduplicates with App.tsx queries
|
||||
- "Account" section label + "Change password" row (gated on `hasLocalCredential`)
|
||||
- "Link OIDC identity" row (gated on `hasLocalCredential && oidcEnabled`)
|
||||
- `ChangePasswordSheet`: current password + new password + confirm; change-password mutation; error copies for mismatch/wrong-current/server
|
||||
- `LinkOidcSheet`: confirmation dialog; body copy uses "your local password will be removed" (passive — no "delete"); "Continue with OIDC" triggers fetchLinkOidc then redirects; no "Authelia" anywhere
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] InstructionSheet.test.tsx broke after SettingsSheet gained useQuery**
|
||||
- **Found during:** Task 3 verification
|
||||
- **Issue:** SettingsSheet now calls useQuery for `['me']` and `['authMode']`. InstructionSheet.test.tsx rendered SettingsSheet without a QueryClientProvider, causing `Error: No QueryClient set, use QueryClientProvider to set one`.
|
||||
- **Fix:** Added `renderWithQueryClient()` helper wrapping `QueryClientProvider`; added `vi.mock('../api/client.js')` with the four new fetch functions.
|
||||
- **Files modified:** `apps/pwa/src/components/InstructionSheet.test.tsx`
|
||||
- **Commit:** `19c45eb`
|
||||
|
||||
**2. [Rule 1 - Bug] Stale eslint-disable directive in App.test.tsx**
|
||||
- **Found during:** Task 3 lint run
|
||||
- **Issue:** `_mockFetchAuthMode` uses `_` prefix naming which already suppresses unused-vars; the explicit eslint-disable comment became an "unused disable directive" error under `--max-warnings 0`.
|
||||
- **Fix:** Removed the `eslint-disable-line` comment.
|
||||
- **Files modified:** `apps/pwa/src/App.test.tsx`
|
||||
- **Commit:** `19c45eb`
|
||||
|
||||
## Playwright-CLI Walkthrough Results
|
||||
|
||||
The dev environment has `DEV_AUTH_BYPASS=true` which makes `/api/me` always return a valid user. The `/api/auth/mode` endpoint returns 404 (plan 19-02 routes not yet active in this dev stack). playwright-cli confirms:
|
||||
- Navigating to /login when authenticated redirects to calendar shell (correct behavior)
|
||||
- No React or TypeScript errors in the browser console
|
||||
|
||||
Full login-form visual/functional verification requires the production-mode stack (no DEV_AUTH_BYPASS, plan 19-02 deployed). This is the checkpoint:human-verify scope.
|
||||
|
||||
## Verification: checkpoint:human-verify Required
|
||||
|
||||
The following surfaces require human verification on the deployed production stack:
|
||||
- Surface 1-10: /login page renders + form interaction + 4 error state variants + OIDC button gate
|
||||
- Surface 11A: add-member form creates a member and it appears in the list
|
||||
- Surface 11B: reset-password sheet opens per-member, submits successfully
|
||||
- Surface 12: change-password sheet validates current password and updates
|
||||
- Surface 13: link-OIDC confirmation shows "local password will be removed" copy then initiates redirect
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Files created exist:
|
||||
- apps/pwa/src/components/BrandSlot.tsx — FOUND
|
||||
- apps/pwa/src/routes/LoginPage.tsx — FOUND
|
||||
|
||||
Commits exist:
|
||||
- 869cdc2 (Task 1) — FOUND
|
||||
- 32d0408 (Task 2) — FOUND
|
||||
- 19c45eb (Task 3) — FOUND
|
||||
|
||||
Tests: 263 passed, 0 failed
|
||||
Typecheck: Clean (tsc --noEmit)
|
||||
Lint: Clean (0 errors, 0 warnings, --max-warnings 0)
|
||||
|
||||
## Known Stubs
|
||||
|
||||
- `BrandSlot` shows "FS" initials and no logo image — intentional Phase 17 seam, not a stub. Phase 17 will override `--brand-logo-*` CSS tokens and may add an `<img>` tag.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
| Flag | File | Description |
|
||||
|------|------|-------------|
|
||||
| threat_flag: credential-in-controlled-state | apps/pwa/src/routes/LoginPage.tsx | Password in useState (controlled input); mitigated: never copied to localStorage/sessionStorage, cleared on success/error/blur |
|
||||
| threat_flag: credential-in-controlled-state | apps/pwa/src/components/SettingsSheet.tsx | currentPassword/newPassword in useState for ChangePasswordSheet; same mitigations |
|
||||
@@ -74,21 +74,33 @@ vi.mock('./routes/ListDetail.js', () => ({
|
||||
ListDetail: () => <div data-testid="list-detail">ListDetail</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./routes/LoginPage.js', () => ({
|
||||
LoginPage: () => <div data-testid="login-page">LoginPage</div>,
|
||||
}));
|
||||
|
||||
// Mock the API client — this is the key mock for the gate
|
||||
vi.mock('./api/client.js', () => ({
|
||||
fetchSetupStatus: vi.fn(),
|
||||
fetchMe: vi.fn(),
|
||||
// Phase 19: fetchAuthMode is queried in App.tsx for the /login gate
|
||||
fetchAuthMode: vi.fn().mockResolvedValue({ localEnabled: true, oidcEnabled: false }),
|
||||
SetupAlreadyLockedError: class SetupAlreadyLockedError extends Error {
|
||||
readonly name = 'SetupAlreadyLockedError';
|
||||
},
|
||||
SessionExpiredError: class SessionExpiredError extends Error {
|
||||
readonly name = 'SessionExpiredError';
|
||||
},
|
||||
LoginError: class LoginError extends Error {
|
||||
readonly name = 'LoginError';
|
||||
constructor(public readonly code: string) {
|
||||
super(`Login failed: ${code}`);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Imports (after mocks) ────────────────────────────────────────────────────
|
||||
|
||||
import { fetchSetupStatus, fetchMe } from './api/client.js';
|
||||
import { fetchSetupStatus, fetchMe, fetchAuthMode } from './api/client.js';
|
||||
import type { Mock } from 'vitest';
|
||||
import App from './App.js';
|
||||
|
||||
@@ -113,6 +125,7 @@ function renderApp(queryClient: QueryClient) {
|
||||
|
||||
const mockFetchSetupStatus = fetchSetupStatus as Mock;
|
||||
const mockFetchMe = fetchMe as Mock;
|
||||
const _mockFetchAuthMode = fetchAuthMode as Mock;
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -129,6 +142,7 @@ describe('App — setup-status gate', () => {
|
||||
color: '#4a90d9',
|
||||
isAdmin: false,
|
||||
needsProviderSetup: false,
|
||||
hasLocalCredential: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
+42
-1
@@ -52,18 +52,33 @@ import { ListsIndex } from './routes/ListsIndex.js';
|
||||
import { ListDetail } from './routes/ListDetail.js';
|
||||
import { AdminPage } from './routes/AdminPage.js';
|
||||
import { SetupPage } from './routes/SetupPage.js';
|
||||
import { LoginPage } from './routes/LoginPage.js';
|
||||
import { BottomTabBar } from './components/BottomTabBar.js';
|
||||
import { AppNav } from './components/AppNav.js';
|
||||
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js';
|
||||
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js';
|
||||
import { SetupBanner } from './components/SetupBanner.js';
|
||||
import { SettingsSheet } from './components/SettingsSheet.js';
|
||||
import { fetchMe, fetchSetupStatus } from './api/client.js';
|
||||
import { fetchMe, fetchSetupStatus, fetchAuthMode } from './api/client.js';
|
||||
|
||||
function isPhone(): boolean {
|
||||
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* OidcRedirect — tiny helper that triggers a top-level navigation to /api/login.
|
||||
*
|
||||
* Used in the auth gate when localEnabled === false and oidcEnabled === true —
|
||||
* the OIDC-only mode that was the app's only auth path before Phase 19.
|
||||
* A top-level navigation (not a React Router navigate) is required because
|
||||
* /api/login responds with a 302 redirect to the external OIDC provider,
|
||||
* which browsers cannot follow as a fetch/XHR (T-07-04).
|
||||
*/
|
||||
function OidcRedirect() {
|
||||
window.location.replace('/api/login');
|
||||
return <div aria-hidden="true" />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const phone = isPhone();
|
||||
@@ -98,6 +113,16 @@ export default function App() {
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// Auth mode query — fetched pre-auth (no session required).
|
||||
// Determines whether to show /login (localEnabled) or OIDC redirect (!localEnabled && oidcEnabled).
|
||||
// staleTime 60s: auth mode changes rarely; re-fetches on new tab/focus.
|
||||
const authModeQuery = useQuery({
|
||||
queryKey: ['authMode'],
|
||||
queryFn: fetchAuthMode,
|
||||
retry: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// isAdmin from /api/me — used for UX gating only (D-03). Server enforces 403.
|
||||
// While meQuery is loading, isAdmin is false/undefined → admin route redirects
|
||||
// (loading gate: no flash of admin content for non-admins).
|
||||
@@ -166,6 +191,15 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* /login route — standalone login page, no AppNav/BottomTabBar shell (UI-SPEC §Surface 1).
|
||||
Phase 19: shown when the user is unauthenticated AND localEnabled === true.
|
||||
The route itself always renders LoginPage (authMode gating is in the `*` route gate below).
|
||||
LoginPage receives authMode so it can show the optional OIDC button when oidcEnabled. */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={<LoginPage authMode={authModeQuery.data} />}
|
||||
/>
|
||||
|
||||
{/* All other routes are gated on setup completion */}
|
||||
<Route
|
||||
path="*"
|
||||
@@ -176,6 +210,13 @@ export default function App() {
|
||||
) : setupComplete === false ? (
|
||||
// Not configured: full-app redirect to /setup (no nav shell rendered)
|
||||
<Navigate to="/setup" replace />
|
||||
) : meQuery.isError && !meQuery.isLoading && authModeQuery.data?.localEnabled ? (
|
||||
// Unauthenticated + localEnabled: redirect to /login
|
||||
<Navigate to="/login" replace />
|
||||
) : meQuery.isError && !meQuery.isLoading && !authModeQuery.data?.localEnabled && authModeQuery.data?.oidcEnabled ? (
|
||||
// Unauthenticated + OIDC-only mode: top-level redirect to /api/login (today's behavior)
|
||||
// Use a render side-effect via useEffect isn't available here; use a helper element
|
||||
<OidcRedirect />
|
||||
) : (
|
||||
// Setup complete: render the normal authenticated app shell
|
||||
<>
|
||||
|
||||
@@ -57,6 +57,185 @@ function handleAuthResponse(res: Response, label: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ── /api/auth/* (Phase 19 — local auth) ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Typed error thrown by fetchLocalLogin when the server returns 401/429/423/5xx.
|
||||
*
|
||||
* Codes:
|
||||
* 'invalid' — 401: incorrect username or password
|
||||
* 'rate-limit' — 429: too many attempts within the rate window
|
||||
* 'locked' — 423: account locked (persistent lockout)
|
||||
* 'server' — 5xx or network: transient server error
|
||||
*
|
||||
* Object.setPrototypeOf is required so instanceof checks work correctly after
|
||||
* TypeScript compilation to ES5 / CommonJS (mirrors SessionExpiredError).
|
||||
*/
|
||||
export class LoginError extends Error {
|
||||
readonly name = 'LoginError';
|
||||
constructor(public readonly code: 'invalid' | 'rate-limit' | 'locked' | 'server') {
|
||||
super(`Login failed: ${code}`);
|
||||
Object.setPrototypeOf(this, LoginError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/mode — pre-auth endpoint, no session required.
|
||||
* Returns whether local-auth and/or OIDC are enabled.
|
||||
* staleTime: 60_000 in App.tsx authModeQuery.
|
||||
*/
|
||||
export async function fetchAuthMode(): Promise<{ localEnabled: boolean; oidcEnabled: boolean }> {
|
||||
const res = await fetch('/api/auth/mode');
|
||||
if (!res.ok) {
|
||||
throw new Error(`fetchAuthMode failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ localEnabled: boolean; oidcEnabled: boolean }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/local/login — submit username/password credentials.
|
||||
*
|
||||
* Maps status codes to typed LoginError:
|
||||
* 401 → LoginError('invalid') — incorrect username or password
|
||||
* 429 → LoginError('rate-limit') — too many attempts
|
||||
* 423 → LoginError('locked') — account locked
|
||||
* other non-ok → LoginError('server')
|
||||
*
|
||||
* Throws nothing on 200 OK — the local-session cookie is set by the server.
|
||||
*/
|
||||
export async function fetchLocalLogin(body: {
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<void> {
|
||||
const res = await fetch('/api/auth/local/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.status === 401) throw new LoginError('invalid');
|
||||
if (res.status === 429) throw new LoginError('rate-limit');
|
||||
if (res.status === 423) throw new LoginError('locked');
|
||||
if (!res.ok) throw new LoginError('server');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/local/logout — clear the local-session cookie.
|
||||
*/
|
||||
export async function fetchLocalLogout(): Promise<void> {
|
||||
const res = await fetch('/api/auth/local/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
if (!res.ok && res.type !== 'opaqueredirect') {
|
||||
throw new Error(`fetchLocalLogout failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/me/password — self-service password change (Phase 19, Surface 12).
|
||||
* Requires the user's current password and a new password (min 8 chars).
|
||||
*
|
||||
* Status codes:
|
||||
* 401 → wrong current password (throws Error with code 'wrong-current')
|
||||
* 422 → validation failure (throws Error with code 'validation')
|
||||
* other non-ok → generic error
|
||||
*/
|
||||
export async function fetchChangePassword(body: {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
}): Promise<void> {
|
||||
const res = await fetch('/api/me/password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json().catch(() => ({}))) as { code?: string };
|
||||
throw new Error(detail.code ?? 'server');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/members — create a new local member account (Phase 19, Surface 11A).
|
||||
* Admin-only; server enforces requireAdmin.
|
||||
*
|
||||
* Status codes:
|
||||
* 409 → username already taken
|
||||
* 422 → validation failure (short password / mismatch)
|
||||
* other non-ok → generic error
|
||||
*/
|
||||
export async function fetchCreateMember(body: {
|
||||
displayName: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<void> {
|
||||
const res = await fetch('/api/admin/members', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json().catch(() => ({}))) as { code?: string };
|
||||
throw new Error(detail.code ?? 'server');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/members/:id/reset-password — admin reset of a member's password (Surface 11B).
|
||||
* Admin-only; server enforces requireAdmin.
|
||||
*/
|
||||
export async function fetchAdminResetPassword(
|
||||
memberId: number,
|
||||
newPassword: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/admin/members/${memberId}/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
body: JSON.stringify({ newPassword }),
|
||||
});
|
||||
|
||||
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
|
||||
if (!res.ok) {
|
||||
throw new Error(`fetchAdminResetPassword failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/me/link-oidc — initiate the OIDC-link flow for the current local user (Surface 13).
|
||||
*
|
||||
* The server returns a redirect URL to begin the OIDC authorization-code flow with a
|
||||
* state parameter encoding the linkUserId claim. The caller should follow the redirect
|
||||
* via top-level navigation (window.location.href = result.redirectUrl).
|
||||
*/
|
||||
export async function fetchLinkOidc(): Promise<{ redirectUrl: string }> {
|
||||
const res = await fetch('/api/me/link-oidc', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
|
||||
if (!res.ok) {
|
||||
throw new Error(`fetchLinkOidc failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ redirectUrl: string }>;
|
||||
}
|
||||
|
||||
// ── /api/me ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MeUser {
|
||||
@@ -65,6 +244,7 @@ export interface MeUser {
|
||||
color: string;
|
||||
isAdmin: boolean; // from users.is_admin — UX gating only (D-03); server enforces 403 on /api/admin/*
|
||||
needsProviderSetup: boolean; // true when no member_credentials row exists for this user
|
||||
hasLocalCredential: boolean; // true when a local_credentials row exists for this user (Phase 19)
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
@@ -363,6 +543,7 @@ export interface AdminMember {
|
||||
displayName: string | null;
|
||||
color: string;
|
||||
hasCredential: boolean;
|
||||
hasLocalCredential: boolean; // true when a local_credentials row exists for this member (Phase 19)
|
||||
}
|
||||
|
||||
export interface AdminMembersResponse {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* BrandSlot — Phase 17 seam component for the login page brand area.
|
||||
*
|
||||
* Phase 19 ships a minimal shippable placeholder: a 48px circle with "FS"
|
||||
* initials, the app name "FamilySync", and the tagline "Family calendar & lists".
|
||||
*
|
||||
* Phase 17 replaces the internals of this component (swap the placeholder div for
|
||||
* an <img> with a real logo) without touching LoginPage's layout. This isolates
|
||||
* the branding seam — see 19-UI-SPEC.md §Brand Slot section.
|
||||
*
|
||||
* CSS custom properties used (all set in tokens.css with placeholder defaults;
|
||||
* Phase 17 overrides these values):
|
||||
* --brand-logo-bg — logo circle background (default: var(--color-member-0))
|
||||
* --brand-logo-text — initials color (default: #ffffff)
|
||||
* --brand-logo-size — circle diameter (default: 48px)
|
||||
* --brand-logo-border-radius — circle shape (default: 50%)
|
||||
*
|
||||
* Accessibility:
|
||||
* <h1> contains the app name — screen readers read "FamilySync" as the page title.
|
||||
* The logo circle is aria-hidden (the text is the accessible label).
|
||||
* No <img> today → no broken image ref → no layout shift when Phase 17 replaces it.
|
||||
*
|
||||
* Security: all copy is plain-text JSX children — no dangerouslySetInnerHTML (T-05-24).
|
||||
*/
|
||||
|
||||
export function BrandSlot() {
|
||||
return (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
{/* Phase 17 replaces this div with <img src="..." alt="" /> */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 'var(--brand-logo-size, 48px)',
|
||||
height: 'var(--brand-logo-size, 48px)',
|
||||
borderRadius: 'var(--brand-logo-border-radius, 50%)',
|
||||
background: 'var(--brand-logo-bg, var(--color-member-0, #4a90d9))',
|
||||
color: 'var(--brand-logo-text, #ffffff)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto var(--space-2, 8px)',
|
||||
fontSize: 'var(--text-display-size, 24px)',
|
||||
fontWeight: 600,
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
flexShrink: 0,
|
||||
aspectRatio: '1 / 1',
|
||||
}}
|
||||
>
|
||||
FS
|
||||
</div>
|
||||
|
||||
{/* App name — <h1> so screen readers identify the page (UI-SPEC §Accessibility) */}
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
marginTop: 'var(--space-2, 8px)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-display-size, 24px)',
|
||||
fontWeight: 600,
|
||||
lineHeight: 'var(--text-display-line-height, 1.2)',
|
||||
color: 'var(--color-text-primary, #111318)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
FamilySync
|
||||
</h1>
|
||||
|
||||
{/* Tagline */}
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
marginBottom: 'var(--space-8, 32px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 400,
|
||||
lineHeight: 'var(--text-body-line-height, 1.5)',
|
||||
color: 'var(--color-text-secondary, #6b7280)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
Family calendar & lists
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,10 @@
|
||||
* - onClose (the sheet-close prop) is NOT called when the dialog opens
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
// ── Module mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -22,6 +24,17 @@ vi.mock('../hooks/usePushSubscription.js', () => ({
|
||||
readNotificationsEnabled: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
// Phase 19: SettingsSheet now calls fetchMe and fetchAuthMode inside useQuery.
|
||||
// Mock client so the test doesn't make real network calls.
|
||||
vi.mock('../api/client.js', () => ({
|
||||
fetchMe: vi.fn().mockResolvedValue({
|
||||
user: { id: 1, displayName: 'Test', color: '#4a90d9', isAdmin: false, needsProviderSetup: false, hasLocalCredential: false },
|
||||
}),
|
||||
fetchAuthMode: vi.fn().mockResolvedValue({ localEnabled: true, oidcEnabled: false }),
|
||||
fetchChangePassword: vi.fn().mockResolvedValue(undefined),
|
||||
fetchLinkOidc: vi.fn().mockResolvedValue({ redirectUrl: '/oidc' }),
|
||||
}));
|
||||
|
||||
// ── Minimal Notification stub (jsdom lacks it) ────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -44,12 +57,21 @@ beforeEach(() => {
|
||||
|
||||
import { SettingsSheet } from './SettingsSheet.js';
|
||||
|
||||
// ── Test helper ───────────────────────────────────────────────────────────────
|
||||
|
||||
function renderWithQueryClient(ui: React.ReactElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('SettingsSheet — "How to enable" wiring (UAT-05-T4)', () => {
|
||||
it('clicking "How to enable" opens the InstructionSheet dialog and does NOT call onClose', () => {
|
||||
const onCloseSpy = vi.fn();
|
||||
render(<SettingsSheet isOpen={true} onClose={onCloseSpy} />);
|
||||
renderWithQueryClient(<SettingsSheet isOpen={true} onClose={onCloseSpy} />);
|
||||
|
||||
// No instruction dialog yet
|
||||
expect(screen.queryByRole('dialog', { name: /re-enable notifications/i })).toBeNull();
|
||||
@@ -71,7 +93,7 @@ describe('SettingsSheet — "How to enable" wiring (UAT-05-T4)', () => {
|
||||
|
||||
it('InstructionSheet "Done" button closes the instruction dialog without calling sheet onClose', () => {
|
||||
const onCloseSpy = vi.fn();
|
||||
render(<SettingsSheet isOpen={true} onClose={onCloseSpy} />);
|
||||
renderWithQueryClient(<SettingsSheet isOpen={true} onClose={onCloseSpy} />);
|
||||
|
||||
// Open the instruction sheet
|
||||
fireEvent.click(screen.getByText('How to enable'));
|
||||
|
||||
@@ -23,8 +23,10 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { X, Bell, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { usePushSubscription } from '../hooks/usePushSubscription.js';
|
||||
import { InstructionSheet } from './InstructionSheet.js';
|
||||
import { fetchMe, fetchAuthMode, fetchChangePassword, fetchLinkOidc } from '../api/client.js';
|
||||
|
||||
// CR-04: fetch VAPID key (from sessionStorage cache if available) for the
|
||||
// tap-gated subscribe() path. Same logic as PushPermissionPrompt.
|
||||
@@ -53,6 +55,28 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
||||
const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription();
|
||||
const [isTogglingOn, setIsTogglingOn] = useState(false);
|
||||
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
||||
|
||||
// Phase 19: read meData (same query key as App.tsx — TanStack deduplicates the request)
|
||||
const meQuery = useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: fetchMe,
|
||||
retry: false,
|
||||
staleTime: 0,
|
||||
});
|
||||
const authModeQuery = useQuery({
|
||||
queryKey: ['authMode'],
|
||||
queryFn: fetchAuthMode,
|
||||
retry: false,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const hasLocalCredential = meQuery.data?.user.hasLocalCredential ?? false;
|
||||
const oidcEnabled = authModeQuery.data?.oidcEnabled ?? false;
|
||||
|
||||
// Change-password sheet state (Surface 12)
|
||||
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
|
||||
// Link-OIDC confirmation sheet state (Surface 13)
|
||||
const [linkOidcOpen, setLinkOidcOpen] = useState(false);
|
||||
// CR-04: pre-fetch the VAPID key into state so the toggle tap handler can call
|
||||
// subscribe(registration, vapidKey) without any network await before pushManager.subscribe().
|
||||
const [vapidKey, setVapidKey] = useState<string | null>(null);
|
||||
@@ -341,6 +365,77 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Surface 12 — Change password row (hasLocalCredential gate) */}
|
||||
{hasLocalCredential && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
height: '1px',
|
||||
background: 'var(--color-border-subtle, var(--color-border))',
|
||||
margin: 'var(--space-4, 16px) 0',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-muted, #9CA3AF)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
marginBottom: 'var(--space-2, 8px)',
|
||||
}}
|
||||
>
|
||||
Account
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChangePasswordOpen(true)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
minHeight: '44px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 'var(--space-2, 8px) 0',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-primary, #111318)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
Change password
|
||||
</button>
|
||||
|
||||
{/* Surface 13 — Link OIDC identity row (hasLocalCredential + oidcEnabled gate) */}
|
||||
{oidcEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLinkOidcOpen(true)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
minHeight: '44px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 'var(--space-2, 8px) 0',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-primary, #111318)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
Link OIDC identity
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Permission-denied hint — only when OS permission === 'denied' */}
|
||||
{permission === 'denied' && (
|
||||
<div
|
||||
@@ -394,6 +489,532 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
||||
</div>
|
||||
|
||||
{instructionsOpen && <InstructionSheet onClose={() => setInstructionsOpen(false)} />}
|
||||
|
||||
{/* Surface 12 — Change-password sheet (hasLocalCredential gate) */}
|
||||
{changePasswordOpen && (
|
||||
<ChangePasswordSheet
|
||||
isOpen={changePasswordOpen}
|
||||
onClose={() => setChangePasswordOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Surface 13 — Link OIDC confirmation sheet (hasLocalCredential + oidcEnabled gate) */}
|
||||
{linkOidcOpen && (
|
||||
<LinkOidcSheet
|
||||
isOpen={linkOidcOpen}
|
||||
onClose={() => setLinkOidcOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ChangePasswordSheet (Surface 12) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Surface 12 — Self-service password change sheet.
|
||||
* Opens from the "Change password" row in SettingsSheet.
|
||||
* Pattern: CredentialSheet (role=dialog, aria-modal, Escape closes, focus heading on open).
|
||||
* Fields: Current password / New password / Confirm — correct autoComplete values.
|
||||
* Security: T-19-18 — password fields are controlled state only; never written to storage.
|
||||
*/
|
||||
|
||||
interface ChangePasswordSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ChangePasswordSheet({ isOpen, onClose }: ChangePasswordSheetProps) {
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handleClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && headingRef.current) {
|
||||
headingRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
function handleClose() {
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError(null);
|
||||
onClose();
|
||||
}
|
||||
|
||||
const changeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (newPassword !== confirmPassword) throw new Error('mismatch');
|
||||
await fetchChangePassword({ currentPassword, newPassword });
|
||||
},
|
||||
onSuccess: () => {
|
||||
handleClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = err instanceof Error ? err.message : 'server';
|
||||
if (msg === 'mismatch') {
|
||||
setError('Passwords do not match.');
|
||||
} else if (msg === 'wrong-current') {
|
||||
setError('Current password is incorrect.');
|
||||
} else {
|
||||
setError('Something went wrong. Please try again.');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const isPending = changeMutation.isPending;
|
||||
const submitDisabled =
|
||||
isPending ||
|
||||
currentPassword.length === 0 ||
|
||||
newPassword.length === 0 ||
|
||||
confirmPassword.length === 0;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'var(--color-overlay, rgba(0,0,0,0.32))',
|
||||
zIndex: 302,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Change password"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'var(--color-surface-raised, #ffffff)',
|
||||
borderRadius: '12px 12px 0 0',
|
||||
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
|
||||
padding: 'var(--space-6, 24px)',
|
||||
zIndex: 303,
|
||||
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
|
||||
maxWidth: '480px',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
margin: '0 0 var(--space-6, 24px) 0',
|
||||
fontSize: 'var(--text-heading-size, 18px)',
|
||||
fontWeight: 600,
|
||||
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
||||
color: 'var(--color-text-primary, #111318)',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
Change password
|
||||
</h2>
|
||||
|
||||
{/* Current password */}
|
||||
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
||||
<label
|
||||
htmlFor="change-current-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary, #111318)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Current password
|
||||
</label>
|
||||
<input
|
||||
id="change-current-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: `1px solid ${error === 'Current password is incorrect.' ? 'var(--color-destructive)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary, #111318)',
|
||||
background: 'var(--color-surface, #ffffff)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* New password */}
|
||||
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
||||
<label
|
||||
htmlFor="change-new-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary, #111318)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
id="change-new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: `1px solid ${error === 'Passwords do not match.' ? 'var(--color-destructive)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary, #111318)',
|
||||
background: 'var(--color-surface, #ffffff)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confirm new password */}
|
||||
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
|
||||
<label
|
||||
htmlFor="change-confirm-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary, #111318)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</label>
|
||||
<input
|
||||
id="change-confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
aria-describedby={error ? 'change-password-error' : undefined}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: `1px solid ${error === 'Passwords do not match.' ? 'var(--color-destructive)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary, #111318)',
|
||||
background: 'var(--color-surface, #ffffff)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
id="change-password-error"
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-destructive, #dc2626)',
|
||||
marginBottom: 'var(--space-4, 16px)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action row */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 'var(--space-3, 12px)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: isPending ? 'default' : 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-secondary, #6b7280)',
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-4, 16px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitDisabled}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
changeMutation.mutate();
|
||||
}}
|
||||
style={{
|
||||
background: submitDisabled
|
||||
? 'var(--color-border, #e2e4e9)'
|
||||
: 'var(--color-member-0, #4a90d9)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
cursor: submitDisabled ? 'default' : 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-4, 16px)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
>
|
||||
Change password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── LinkOidcSheet (Surface 13) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Surface 13 — Link OIDC identity confirmation sheet.
|
||||
* NOT a form — the actual linking happens via OIDC redirect.
|
||||
* Two-step confirmation: open sheet (step 1) + tap "Continue with OIDC" (step 2).
|
||||
*
|
||||
* Copywriting rules (UI-SPEC §Copywriting Contract):
|
||||
* - Never use "Authelia" (D-06) — use "your OIDC provider"
|
||||
* - Never say "delete" or "remove" when describing consequence — use "will be removed" (passive)
|
||||
* - Body copy: non-alarming, frames linking as an upgrade
|
||||
*
|
||||
* Security: T-19-21 — no provider-specific branding that leaks infrastructure details.
|
||||
*/
|
||||
|
||||
interface LinkOidcSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && headingRef.current) {
|
||||
headingRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const linkMutation = useMutation({
|
||||
mutationFn: fetchLinkOidc,
|
||||
onSuccess: (data) => {
|
||||
// Close the sheet and initiate OIDC link flow
|
||||
onClose();
|
||||
window.location.href = data.redirectUrl;
|
||||
},
|
||||
onError: () => {
|
||||
setError('Something went wrong. Please try again.');
|
||||
},
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'var(--color-overlay, rgba(0,0,0,0.32))',
|
||||
zIndex: 302,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Link OIDC identity"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'var(--color-surface-raised, #ffffff)',
|
||||
borderRadius: '12px 12px 0 0',
|
||||
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
|
||||
padding: 'var(--space-6, 24px)',
|
||||
zIndex: 303,
|
||||
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
|
||||
maxWidth: '480px',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
margin: '0 0 var(--space-4, 16px) 0',
|
||||
fontSize: 'var(--text-heading-size, 18px)',
|
||||
fontWeight: 600,
|
||||
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
||||
color: 'var(--color-text-primary, #111318)',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
Link OIDC identity
|
||||
</h2>
|
||||
|
||||
{/* Body — informational, not alarming (UI-SPEC §Copywriting Contract) */}
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 var(--space-3, 12px) 0',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 400,
|
||||
lineHeight: 'var(--text-body-line-height, 1.5)',
|
||||
color: 'var(--color-text-secondary, #6b7280)',
|
||||
}}
|
||||
>
|
||||
{"After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed."}
|
||||
</p>
|
||||
|
||||
{/* Secondary note */}
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 var(--space-6, 24px) 0',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
lineHeight: 'var(--text-label-line-height, 1.4)',
|
||||
color: 'var(--color-text-muted, #9ca3af)',
|
||||
}}
|
||||
>
|
||||
{"This can't be undone from the app. Contact your admin if you need to revert."}
|
||||
</p>
|
||||
|
||||
{/* Error (post-fetch) */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
color: 'var(--color-destructive, #dc2626)',
|
||||
marginBottom: 'var(--space-4, 16px)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action row */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 'var(--space-3, 12px)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={linkMutation.isPending}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: linkMutation.isPending ? 'default' : 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-secondary, #6b7280)',
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-4, 16px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={linkMutation.isPending}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
linkMutation.mutate();
|
||||
}}
|
||||
style={{
|
||||
background: linkMutation.isPending
|
||||
? 'var(--color-border, #e2e4e9)'
|
||||
: 'var(--color-member-0, #4a90d9)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
cursor: linkMutation.isPending ? 'default' : 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-4, 16px)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
>
|
||||
Continue with OIDC
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,15 +23,17 @@
|
||||
* Security: client isAdmin gate is UX only. Server 403 is the real boundary (D-03).
|
||||
*/
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
fetchAdminMembers,
|
||||
fetchAdminCalendars,
|
||||
setSharedCalendar,
|
||||
fetchAdminTimezone,
|
||||
setAdminTimezone,
|
||||
fetchCreateMember,
|
||||
fetchAdminResetPassword,
|
||||
type AdminMember,
|
||||
type AdminCalendar,
|
||||
} from '../api/client.js';
|
||||
@@ -59,6 +61,19 @@ export function AdminPage() {
|
||||
const [sheetMember, setSheetMember] = useState<AdminMember | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Reset-password sheet state (Surface 11B)
|
||||
const [resetSheetOpen, setResetSheetOpen] = useState(false);
|
||||
const [resetTargetMember, setResetTargetMember] = useState<AdminMember | null>(null);
|
||||
// resetTriggerRef: stores the exact button that opened the reset sheet so focus can return on close
|
||||
const resetTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
// Create-member form state (Surface 11A)
|
||||
const [createDisplayName, setCreateDisplayName] = useState('');
|
||||
const [createUsername, setCreateUsername] = useState('');
|
||||
const [createPassword, setCreatePassword] = useState('');
|
||||
const [createConfirmPassword, setCreateConfirmPassword] = useState('');
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
// Shared calendar picker state
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null);
|
||||
|
||||
@@ -179,6 +194,53 @@ export function AdminPage() {
|
||||
setSheetOpen(true);
|
||||
}
|
||||
|
||||
// Create-member mutation (Surface 11A)
|
||||
const createMemberMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Client-side validation (server also validates; this is for UX)
|
||||
if (createPassword !== createConfirmPassword) {
|
||||
throw new Error('mismatch');
|
||||
}
|
||||
if (createPassword.length < 8) {
|
||||
throw new Error('short');
|
||||
}
|
||||
await fetchCreateMember({
|
||||
displayName: createDisplayName.trim(),
|
||||
username: createUsername.trim(),
|
||||
password: createPassword,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Clear form + refresh member list
|
||||
setCreateDisplayName('');
|
||||
setCreateUsername('');
|
||||
setCreatePassword('');
|
||||
setCreateConfirmPassword('');
|
||||
setCreateError(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = err instanceof Error ? err.message : 'server';
|
||||
if (msg === 'mismatch') {
|
||||
setCreateError('Passwords do not match.');
|
||||
} else if (msg === 'short') {
|
||||
setCreateError('Password is too short. Use at least 8 characters.');
|
||||
} else if (msg === 'conflict' || msg.includes('409')) {
|
||||
setCreateError('That username is already in use. Choose a different one.');
|
||||
} else {
|
||||
setCreateError('Something went wrong. Please try again.');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const createSubmitDisabled =
|
||||
createMemberMutation.isPending ||
|
||||
createDisplayName.trim().length === 0 ||
|
||||
createUsername.trim().length === 0 ||
|
||||
createPassword.length === 0 ||
|
||||
createConfirmPassword.length === 0;
|
||||
|
||||
const saveDisabled =
|
||||
sharedCalMutation.isPending ||
|
||||
effectiveSelected === null ||
|
||||
@@ -250,6 +312,12 @@ export function AdminPage() {
|
||||
member={member}
|
||||
colorIndex={idx}
|
||||
onAction={(buttonRef) => openSheet(member, buttonRef)}
|
||||
onResetPassword={(buttonRef) => {
|
||||
// Capture trigger button so focus can return on close
|
||||
resetTriggerRef.current = buttonRef.current;
|
||||
setResetTargetMember(member);
|
||||
setResetSheetOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -620,6 +688,230 @@ export function AdminPage() {
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
{/* ── LOCAL ACCOUNTS section ──────────────────────────────────────── */}
|
||||
<section aria-label="Local Accounts" style={{ marginBottom: 'var(--space-8, 32px)' }}>
|
||||
<div style={sectionLabelStyle}>Local Accounts</div>
|
||||
|
||||
{/* Surface 11A — Add member inline form */}
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--color-border-subtle, var(--color-border))',
|
||||
borderRadius: '8px',
|
||||
padding: 'var(--space-4, 16px)',
|
||||
marginBottom: 'var(--space-6, 24px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-4, 16px)',
|
||||
}}
|
||||
>
|
||||
Add member
|
||||
</div>
|
||||
|
||||
{/* Display name */}
|
||||
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
||||
<label
|
||||
htmlFor="admin-create-display-name"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Display name
|
||||
</label>
|
||||
<input
|
||||
id="admin-create-display-name"
|
||||
type="text"
|
||||
value={createDisplayName}
|
||||
onChange={(e) => setCreateDisplayName(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
||||
<label
|
||||
htmlFor="admin-create-username"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="admin-create-username"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
value={createUsername}
|
||||
onChange={(e) => setCreateUsername(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Initial password */}
|
||||
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
||||
<label
|
||||
htmlFor="admin-create-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Initial password
|
||||
</label>
|
||||
<input
|
||||
id="admin-create-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={createPassword}
|
||||
onChange={(e) => setCreatePassword(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confirm password */}
|
||||
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
|
||||
<label
|
||||
htmlFor="admin-create-confirm-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="admin-create-confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={createConfirmPassword}
|
||||
onChange={(e) => setCreateConfirmPassword(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Inline error */}
|
||||
{createError && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-destructive)',
|
||||
marginBottom: 'var(--space-3, 12px)',
|
||||
}}
|
||||
>
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action row */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={createSubmitDisabled}
|
||||
onClick={() => {
|
||||
setCreateError(null);
|
||||
createMemberMutation.mutate();
|
||||
}}
|
||||
style={{
|
||||
background: createSubmitDisabled
|
||||
? 'var(--color-border, #e2e4e9)'
|
||||
: 'var(--color-member-0, #4a90d9)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
cursor: createSubmitDisabled ? 'default' : 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-6, 24px)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
transition: 'background 0.15s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2, 8px)',
|
||||
}}
|
||||
>
|
||||
{createMemberMutation.isPending && (
|
||||
<Loader2
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
Add member
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Credential sheet — admin-rotate or admin-add */}
|
||||
@@ -633,6 +925,21 @@ export function AdminPage() {
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Surface 11B — Reset password sheet */}
|
||||
{resetTargetMember && (
|
||||
<ResetPasswordSheet
|
||||
isOpen={resetSheetOpen}
|
||||
onClose={() => {
|
||||
setResetSheetOpen(false);
|
||||
// Return focus to trigger
|
||||
if (resetTriggerRef.current) {
|
||||
resetTriggerRef.current.focus();
|
||||
}
|
||||
}}
|
||||
member={resetTargetMember}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -643,10 +950,12 @@ interface MemberRowProps {
|
||||
member: AdminMember;
|
||||
colorIndex: number;
|
||||
onAction: (buttonRef: React.RefObject<HTMLButtonElement | null>) => void;
|
||||
onResetPassword?: (buttonRef: React.RefObject<HTMLButtonElement | null>) => void;
|
||||
}
|
||||
|
||||
function MemberRow({ member, colorIndex, onAction }: MemberRowProps) {
|
||||
function MemberRow({ member, colorIndex, onAction, onResetPassword }: MemberRowProps) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const resetBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -731,28 +1040,54 @@ function MemberRow({ member, colorIndex, onAction }: MemberRowProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => onAction(buttonRef)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-3, 12px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{member.hasCredential ? 'Rotate' : 'Add credential'}
|
||||
</button>
|
||||
{/* Action button row */}
|
||||
<div style={{ display: 'flex', gap: 'var(--space-2, 8px)', flexShrink: 0 }}>
|
||||
{/* Credential rotate/add button */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => onAction(buttonRef)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-3, 12px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
{member.hasCredential ? 'Rotate' : 'Add credential'}
|
||||
</button>
|
||||
|
||||
{/* Surface 11B — Reset password button (only for members with a local credential) */}
|
||||
{member.hasLocalCredential && onResetPassword && (
|
||||
<button
|
||||
ref={resetBtnRef}
|
||||
type="button"
|
||||
onClick={() => onResetPassword(resetBtnRef)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-3, 12px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
Reset password
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -842,6 +1177,285 @@ function CalendarRadioRow({ calendar, isSelected, onSelect }: CalendarRadioRowPr
|
||||
);
|
||||
}
|
||||
|
||||
// ── ResetPasswordSheet ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Surface 11B — Admin password reset sheet.
|
||||
* Opens as a bottom sheet (mobile) / centered modal (desktop).
|
||||
* Pattern: CredentialSheet (role=dialog, aria-modal, Escape closes, focus returns to trigger).
|
||||
* No current-password field — admin reset does not require knowing the old password.
|
||||
*/
|
||||
|
||||
interface ResetPasswordSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
member: AdminMember;
|
||||
}
|
||||
|
||||
function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps) {
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
// Escape closes the sheet
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Focus heading on open
|
||||
useEffect(() => {
|
||||
if (isOpen && headingRef.current) {
|
||||
headingRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
function handleClose() {
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError(null);
|
||||
onClose();
|
||||
}
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (newPassword !== confirmPassword) throw new Error('mismatch');
|
||||
await fetchAdminResetPassword(member.id, newPassword);
|
||||
},
|
||||
onSuccess: () => {
|
||||
handleClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = err instanceof Error ? err.message : 'server';
|
||||
if (msg === 'mismatch') {
|
||||
setError('Passwords do not match.');
|
||||
} else {
|
||||
setError('Something went wrong. Please try again.');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const isPending = resetMutation.isPending;
|
||||
const submitDisabled =
|
||||
isPending || newPassword.length === 0 || confirmPassword.length === 0;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'var(--color-overlay, rgba(0,0,0,0.32))',
|
||||
zIndex: 300,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Reset password"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'var(--color-surface, #ffffff)',
|
||||
borderRadius: '12px 12px 0 0',
|
||||
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
|
||||
padding: 'var(--space-6, 24px)',
|
||||
zIndex: 301,
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
maxWidth: '480px',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
margin: '0 0 var(--space-1, 4px) 0',
|
||||
fontSize: 'var(--text-heading-size, 18px)',
|
||||
fontWeight: 600,
|
||||
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
||||
color: 'var(--color-text-primary)',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
Reset password
|
||||
</h2>
|
||||
|
||||
{/* Member subtitle */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-secondary)',
|
||||
marginBottom: 'var(--space-6, 24px)',
|
||||
}}
|
||||
>
|
||||
{member.displayName ?? 'Member'}
|
||||
</div>
|
||||
|
||||
{/* New password */}
|
||||
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
||||
<label
|
||||
htmlFor="reset-new-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
id="reset-new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: `1px solid ${error ? 'var(--color-destructive)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confirm new password */}
|
||||
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
|
||||
<label
|
||||
htmlFor="reset-confirm-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Confirm new password
|
||||
</label>
|
||||
<input
|
||||
id="reset-confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
aria-describedby={error ? 'reset-error' : undefined}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: `1px solid ${error ? 'var(--color-destructive)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Inline error */}
|
||||
{error && (
|
||||
<div
|
||||
id="reset-error"
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-destructive)',
|
||||
marginBottom: 'var(--space-4, 16px)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action row */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 'var(--space-3, 12px)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: isPending ? 'default' : 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-secondary)',
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-4, 16px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitDisabled}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
resetMutation.mutate();
|
||||
}}
|
||||
style={{
|
||||
background: submitDisabled
|
||||
? 'var(--color-border, #e2e4e9)'
|
||||
: 'var(--color-member-0, #4a90d9)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
cursor: submitDisabled ? 'default' : 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-4, 16px)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
>
|
||||
Reset password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── EmptyCalendarsState ─────────────────────────────────────────────────────
|
||||
|
||||
function EmptyCalendarsState() {
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* LoginPage — standalone /login route (Phase 19, D-04).
|
||||
*
|
||||
* UI-SPEC §Surface Architecture Surfaces 1–10:
|
||||
* Surface 1: Full-page standalone route (no AppNav/BottomTabBar/SetupBanner)
|
||||
* Surface 2: BrandSlot — app logo placeholder + name + tagline (Phase 17 seam)
|
||||
* Surface 3: Login card — "Sign in" heading
|
||||
* Surface 4: Username field (id="login-username", spellCheck/autoCapitalize/autoCorrect off)
|
||||
* Surface 5: Password field with show/hide toggle (Eye/EyeOff, 44px tap target)
|
||||
* Surface 6: Error/lockout banner (role="status", aria-live="polite", 4 error variants)
|
||||
* Surface 7: Primary "Sign in" / "Signing in…" submit button (full-width)
|
||||
* Surface 8: Method divider ("or") — rendered only when oidcEnabled
|
||||
* Surface 9: "Login with OIDC" outlined button — rendered only when oidcEnabled
|
||||
* Surface 10: "Forgot your password? Ask your admin." helper (informational only)
|
||||
*
|
||||
* Auth gate: App.tsx renders this route when meQuery returns 401 AND localEnabled.
|
||||
* On success: window.location.replace('/') — the cookie is set by the API server.
|
||||
*
|
||||
* Security:
|
||||
* T-19-18: password field is controlled state only; never written to localStorage/sessionStorage
|
||||
* T-19-19: single shared "Incorrect username or password." — no field-level blame
|
||||
* T-19-20: plain-text JSX children; no dangerouslySetInnerHTML (T-05-24)
|
||||
* T-19-21: D-06 — UI never renders the provider name; uses generic "Login with OIDC"
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { AlertCircle, Eye, EyeOff, Loader2, ShieldCheck } from 'lucide-react';
|
||||
import { fetchLocalLogin, LoginError } from '../api/client.js';
|
||||
import { BrandSlot } from '../components/BrandSlot.js';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface LoginPageProps {
|
||||
authMode?: { localEnabled: boolean; oidcEnabled: boolean };
|
||||
}
|
||||
|
||||
// ── Styles (copied from SetupPage.tsx — UI-SPEC §Design System) ───────────────
|
||||
|
||||
const pageStyle: React.CSSProperties = {
|
||||
minHeight: '100dvh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
background: 'var(--color-surface, #ffffff)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
color: 'var(--color-text-primary, #111318)',
|
||||
};
|
||||
|
||||
const contentColStyle: React.CSSProperties = {
|
||||
maxWidth: '400px', // login card is narrower than setup wizard (UI-SPEC Surface 1)
|
||||
width: '100%',
|
||||
margin: '0 auto',
|
||||
padding: 'var(--space-12, 48px) var(--space-6, 24px)',
|
||||
};
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
background: 'var(--color-surface, #ffffff)',
|
||||
border: '1px solid var(--color-border, #e2e4e9)',
|
||||
borderRadius: '8px',
|
||||
padding: 'var(--space-6, 24px)',
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
|
||||
};
|
||||
|
||||
const primaryBtnStyle = (disabled: boolean): React.CSSProperties => ({
|
||||
background: disabled ? 'var(--color-border, #e2e4e9)' : 'var(--color-member-0, #4a90d9)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
width: '100%',
|
||||
padding: '0 var(--space-6, 24px)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
transition: 'background 0.15s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 'var(--space-2, 8px)',
|
||||
});
|
||||
|
||||
const inputStyle = (hasError: boolean): React.CSSProperties => ({
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: `1px solid ${hasError ? 'var(--color-destructive, #dc2626)' : 'var(--color-border, #e2e4e9)'}`,
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary, #111318)',
|
||||
background: 'var(--color-surface, #ffffff)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
});
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary, #111318)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
};
|
||||
|
||||
// ── LoginPage ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function LoginPage({ authMode }: LoginPageProps) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loginError, setLoginError] = useState<
|
||||
'invalid' | 'rate-limit' | 'locked' | 'server' | null
|
||||
>(null);
|
||||
|
||||
// Ref for moving focus to the error heading on error (UI-SPEC §Focus Management)
|
||||
const errorHeadingRef = useRef<HTMLDivElement>(null);
|
||||
// Ref for password field so Enter in username moves focus there
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const oidcEnabled = authMode?.oidcEnabled ?? false;
|
||||
|
||||
// Move focus to error banner heading when error state activates
|
||||
useEffect(() => {
|
||||
if (loginError && errorHeadingRef.current) {
|
||||
errorHeadingRef.current.focus();
|
||||
}
|
||||
}, [loginError]);
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: () => fetchLocalLogin({ username, password }),
|
||||
onSuccess: () => {
|
||||
// Cookie is set by the API; replace to clear the /login URL from history
|
||||
window.location.replace('/');
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof LoginError) {
|
||||
setLoginError(err.code);
|
||||
} else {
|
||||
setLoginError('server');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const isLoading = loginMutation.isPending;
|
||||
const bothNonEmpty = username.trim().length > 0 && password.length > 0;
|
||||
const submitDisabled =
|
||||
isLoading ||
|
||||
!bothNonEmpty ||
|
||||
loginError === 'rate-limit' ||
|
||||
loginError === 'locked';
|
||||
|
||||
// Derive whether inputs should show error state
|
||||
const inputHasError = loginError === 'invalid';
|
||||
|
||||
function handleSubmit() {
|
||||
if (submitDisabled) return;
|
||||
setLoginError(null);
|
||||
loginMutation.mutate();
|
||||
}
|
||||
|
||||
function handleUsernameKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
passwordRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function handlePasswordKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={pageStyle}>
|
||||
<div style={contentColStyle} role="main">
|
||||
{/* Surface 2 — Brand Slot (above the login card, in the flow) */}
|
||||
<BrandSlot />
|
||||
|
||||
{/* Surface 3 — Login Card */}
|
||||
<div style={cardStyle}>
|
||||
<h2
|
||||
style={{
|
||||
margin: '0 0 var(--space-6, 24px) 0',
|
||||
fontSize: 'var(--text-heading-size, 18px)',
|
||||
fontWeight: 600,
|
||||
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
||||
color: 'var(--color-text-primary, #111318)',
|
||||
}}
|
||||
>
|
||||
Sign in
|
||||
</h2>
|
||||
|
||||
{/* Surface 4 — Username field */}
|
||||
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
|
||||
<label htmlFor="login-username" style={labelStyle}>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="login-username"
|
||||
type="text"
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={handleUsernameKeyDown}
|
||||
aria-describedby={loginError ? 'login-error' : undefined}
|
||||
style={inputStyle(inputHasError)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Surface 5 — Password field with show/hide toggle */}
|
||||
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
|
||||
<label htmlFor="login-password" style={labelStyle}>
|
||||
Password
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
id="login-password"
|
||||
ref={passwordRef}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={handlePasswordKeyDown}
|
||||
onBlur={() => setShowPassword(false)}
|
||||
aria-describedby={loginError ? 'login-error' : undefined}
|
||||
style={{ ...inputStyle(inputHasError), paddingRight: '44px' }}
|
||||
/>
|
||||
{/* Show/hide toggle button — 44px tap target (UI-SPEC Surface 5) */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
aria-pressed={showPassword}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: '100%',
|
||||
minWidth: '44px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--color-text-muted, #9ca3af)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<Eye size={16} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Surface 6 — Error / lockout banner */}
|
||||
{loginError && (
|
||||
<div
|
||||
id="login-error"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
style={{ marginBottom: 'var(--space-4, 16px)' }}
|
||||
>
|
||||
{loginError === 'invalid' && (
|
||||
<div
|
||||
ref={errorHeadingRef}
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2, 8px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-destructive, #dc2626)',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={16} aria-hidden="true" style={{ flexShrink: 0 }} />
|
||||
Incorrect username or password.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loginError === 'rate-limit' && (
|
||||
<div
|
||||
ref={errorHeadingRef}
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2, 8px)',
|
||||
background: 'var(--color-surface-dim, #f7f7f8)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-destructive, #dc2626)',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={16} aria-hidden="true" style={{ flexShrink: 0 }} />
|
||||
Too many attempts. Please wait a moment and try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loginError === 'locked' && (
|
||||
<div
|
||||
ref={errorHeadingRef}
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2, 8px)',
|
||||
background: 'var(--color-surface-dim, #f7f7f8)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-destructive, #dc2626)',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={16} aria-hidden="true" style={{ flexShrink: 0 }} />
|
||||
This account is temporarily locked. Contact your admin to reset access.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loginError === 'server' && (
|
||||
<div
|
||||
ref={errorHeadingRef}
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2, 8px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-destructive, #dc2626)',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
<AlertCircle size={16} aria-hidden="true" style={{ flexShrink: 0 }} />
|
||||
Something went wrong. Please try again.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Surface 7 — Primary submit button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitDisabled}
|
||||
style={primaryBtnStyle(submitDisabled)}
|
||||
>
|
||||
{isLoading && (
|
||||
<Loader2
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
{isLoading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
|
||||
{/* Surface 10 — Forgot password helper (informational only, not interactive) */}
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
marginTop: 'var(--space-4, 16px)',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-secondary, #6b7280)',
|
||||
textAlign: 'center',
|
||||
lineHeight: 'var(--text-label-line-height, 1.4)',
|
||||
}}
|
||||
>
|
||||
Forgot your password? Ask your admin.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Surfaces 8 & 9 — Method divider + OIDC button (only when oidcEnabled) */}
|
||||
{oidcEnabled && (
|
||||
<>
|
||||
{/* Surface 8 — Method divider */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-3, 12px)',
|
||||
marginTop: 'var(--space-4, 16px)',
|
||||
marginBottom: 'var(--space-4, 16px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '1px',
|
||||
background: 'var(--color-border, #e2e4e9)',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-secondary, #6b7280)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
or
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '1px',
|
||||
background: 'var(--color-border, #e2e4e9)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Surface 9 — OIDC login button — uses generic copy per D-06 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Initiate OIDC authorization-code flow (same redirect as today's OIDC-only mode)
|
||||
window.location.href = '/api/login';
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '44px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--color-member-0, #4a90d9)',
|
||||
color: 'var(--color-member-0, #4a90d9)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 'var(--space-2, 8px)',
|
||||
}}
|
||||
>
|
||||
<ShieldCheck size={16} aria-hidden="true" />
|
||||
Login with OIDC
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -88,6 +88,18 @@
|
||||
--text-display-weight: 600;
|
||||
--text-display-line-height: 1.2;
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* BRAND SLOT — Phase 17 seam tokens
|
||||
* Phase 19 sets placeholder defaults; Phase 17 overrides these values only —
|
||||
* never the BrandSlot component structure (see 19-UI-SPEC.md §Brand Slot).
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
--brand-logo-bg: var(--color-member-0); /* placeholder circle background */
|
||||
--brand-logo-text: #ffffff; /* placeholder initials color */
|
||||
--brand-logo-size: 48px; /* reserved slot height; keep 1:1 aspect */
|
||||
--brand-logo-border-radius: 50%; /* circle for initials; Phase 17 may change */
|
||||
--brand-app-name: 'FamilySync'; /* drives doc only — not used as CSS content */
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
* BREAKPOINTS (reference; use in @media queries)
|
||||
* ───────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user