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>,
|
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
|
// Mock the API client — this is the key mock for the gate
|
||||||
vi.mock('./api/client.js', () => ({
|
vi.mock('./api/client.js', () => ({
|
||||||
fetchSetupStatus: vi.fn(),
|
fetchSetupStatus: vi.fn(),
|
||||||
fetchMe: 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 {
|
SetupAlreadyLockedError: class SetupAlreadyLockedError extends Error {
|
||||||
readonly name = 'SetupAlreadyLockedError';
|
readonly name = 'SetupAlreadyLockedError';
|
||||||
},
|
},
|
||||||
SessionExpiredError: class SessionExpiredError extends Error {
|
SessionExpiredError: class SessionExpiredError extends Error {
|
||||||
readonly name = 'SessionExpiredError';
|
readonly name = 'SessionExpiredError';
|
||||||
},
|
},
|
||||||
|
LoginError: class LoginError extends Error {
|
||||||
|
readonly name = 'LoginError';
|
||||||
|
constructor(public readonly code: string) {
|
||||||
|
super(`Login failed: ${code}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Imports (after mocks) ────────────────────────────────────────────────────
|
// ── Imports (after mocks) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
import { fetchSetupStatus, fetchMe } from './api/client.js';
|
import { fetchSetupStatus, fetchMe, fetchAuthMode } from './api/client.js';
|
||||||
import type { Mock } from 'vitest';
|
import type { Mock } from 'vitest';
|
||||||
import App from './App.js';
|
import App from './App.js';
|
||||||
|
|
||||||
@@ -113,6 +125,7 @@ function renderApp(queryClient: QueryClient) {
|
|||||||
|
|
||||||
const mockFetchSetupStatus = fetchSetupStatus as Mock;
|
const mockFetchSetupStatus = fetchSetupStatus as Mock;
|
||||||
const mockFetchMe = fetchMe as Mock;
|
const mockFetchMe = fetchMe as Mock;
|
||||||
|
const _mockFetchAuthMode = fetchAuthMode as Mock;
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -129,6 +142,7 @@ describe('App — setup-status gate', () => {
|
|||||||
color: '#4a90d9',
|
color: '#4a90d9',
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
needsProviderSetup: false,
|
needsProviderSetup: false,
|
||||||
|
hasLocalCredential: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+42
-1
@@ -52,18 +52,33 @@ import { ListsIndex } from './routes/ListsIndex.js';
|
|||||||
import { ListDetail } from './routes/ListDetail.js';
|
import { ListDetail } from './routes/ListDetail.js';
|
||||||
import { AdminPage } from './routes/AdminPage.js';
|
import { AdminPage } from './routes/AdminPage.js';
|
||||||
import { SetupPage } from './routes/SetupPage.js';
|
import { SetupPage } from './routes/SetupPage.js';
|
||||||
|
import { LoginPage } from './routes/LoginPage.js';
|
||||||
import { BottomTabBar } from './components/BottomTabBar.js';
|
import { BottomTabBar } from './components/BottomTabBar.js';
|
||||||
import { AppNav } from './components/AppNav.js';
|
import { AppNav } from './components/AppNav.js';
|
||||||
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js';
|
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js';
|
||||||
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js';
|
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js';
|
||||||
import { SetupBanner } from './components/SetupBanner.js';
|
import { SetupBanner } from './components/SetupBanner.js';
|
||||||
import { SettingsSheet } from './components/SettingsSheet.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 {
|
function isPhone(): boolean {
|
||||||
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
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() {
|
export default function App() {
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const phone = isPhone();
|
const phone = isPhone();
|
||||||
@@ -98,6 +113,16 @@ export default function App() {
|
|||||||
staleTime: 0,
|
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.
|
// 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
|
// While meQuery is loading, isAdmin is false/undefined → admin route redirects
|
||||||
// (loading gate: no flash of admin content for non-admins).
|
// (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 */}
|
{/* All other routes are gated on setup completion */}
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
@@ -176,6 +210,13 @@ export default function App() {
|
|||||||
) : setupComplete === false ? (
|
) : setupComplete === false ? (
|
||||||
// Not configured: full-app redirect to /setup (no nav shell rendered)
|
// Not configured: full-app redirect to /setup (no nav shell rendered)
|
||||||
<Navigate to="/setup" replace />
|
<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
|
// 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 ────────────────────────────────────────────────────────────────
|
// ── /api/me ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface MeUser {
|
export interface MeUser {
|
||||||
@@ -65,6 +244,7 @@ export interface MeUser {
|
|||||||
color: string;
|
color: string;
|
||||||
isAdmin: boolean; // from users.is_admin — UX gating only (D-03); server enforces 403 on /api/admin/*
|
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
|
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 {
|
export interface MeResponse {
|
||||||
@@ -363,6 +543,7 @@ export interface AdminMember {
|
|||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
color: string;
|
color: string;
|
||||||
hasCredential: boolean;
|
hasCredential: boolean;
|
||||||
|
hasLocalCredential: boolean; // true when a local_credentials row exists for this member (Phase 19)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminMembersResponse {
|
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
|
* - 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 { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
// ── Module mocks ──────────────────────────────────────────────────────────────
|
// ── Module mocks ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -22,6 +24,17 @@ vi.mock('../hooks/usePushSubscription.js', () => ({
|
|||||||
readNotificationsEnabled: vi.fn(() => false),
|
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) ────────────────────────────────
|
// ── Minimal Notification stub (jsdom lacks it) ────────────────────────────────
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -44,12 +57,21 @@ beforeEach(() => {
|
|||||||
|
|
||||||
import { SettingsSheet } from './SettingsSheet.js';
|
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 ─────────────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('SettingsSheet — "How to enable" wiring (UAT-05-T4)', () => {
|
describe('SettingsSheet — "How to enable" wiring (UAT-05-T4)', () => {
|
||||||
it('clicking "How to enable" opens the InstructionSheet dialog and does NOT call onClose', () => {
|
it('clicking "How to enable" opens the InstructionSheet dialog and does NOT call onClose', () => {
|
||||||
const onCloseSpy = vi.fn();
|
const onCloseSpy = vi.fn();
|
||||||
render(<SettingsSheet isOpen={true} onClose={onCloseSpy} />);
|
renderWithQueryClient(<SettingsSheet isOpen={true} onClose={onCloseSpy} />);
|
||||||
|
|
||||||
// No instruction dialog yet
|
// No instruction dialog yet
|
||||||
expect(screen.queryByRole('dialog', { name: /re-enable notifications/i })).toBeNull();
|
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', () => {
|
it('InstructionSheet "Done" button closes the instruction dialog without calling sheet onClose', () => {
|
||||||
const onCloseSpy = vi.fn();
|
const onCloseSpy = vi.fn();
|
||||||
render(<SettingsSheet isOpen={true} onClose={onCloseSpy} />);
|
renderWithQueryClient(<SettingsSheet isOpen={true} onClose={onCloseSpy} />);
|
||||||
|
|
||||||
// Open the instruction sheet
|
// Open the instruction sheet
|
||||||
fireEvent.click(screen.getByText('How to enable'));
|
fireEvent.click(screen.getByText('How to enable'));
|
||||||
|
|||||||
@@ -23,8 +23,10 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { X, Bell, AlertCircle, Loader2 } from 'lucide-react';
|
import { X, Bell, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
import { usePushSubscription } from '../hooks/usePushSubscription.js';
|
import { usePushSubscription } from '../hooks/usePushSubscription.js';
|
||||||
import { InstructionSheet } from './InstructionSheet.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
|
// CR-04: fetch VAPID key (from sessionStorage cache if available) for the
|
||||||
// tap-gated subscribe() path. Same logic as PushPermissionPrompt.
|
// 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 { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription();
|
||||||
const [isTogglingOn, setIsTogglingOn] = useState(false);
|
const [isTogglingOn, setIsTogglingOn] = useState(false);
|
||||||
const [instructionsOpen, setInstructionsOpen] = 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
|
// 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().
|
// subscribe(registration, vapidKey) without any network await before pushManager.subscribe().
|
||||||
const [vapidKey, setVapidKey] = useState<string | null>(null);
|
const [vapidKey, setVapidKey] = useState<string | null>(null);
|
||||||
@@ -341,6 +365,77 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 hint — only when OS permission === 'denied' */}
|
||||||
{permission === 'denied' && (
|
{permission === 'denied' && (
|
||||||
<div
|
<div
|
||||||
@@ -394,6 +489,532 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{instructionsOpen && <InstructionSheet onClose={() => setInstructionsOpen(false)} />}
|
{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).
|
* 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { CheckCircle, AlertCircle } from 'lucide-react';
|
import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
fetchAdminMembers,
|
fetchAdminMembers,
|
||||||
fetchAdminCalendars,
|
fetchAdminCalendars,
|
||||||
setSharedCalendar,
|
setSharedCalendar,
|
||||||
fetchAdminTimezone,
|
fetchAdminTimezone,
|
||||||
setAdminTimezone,
|
setAdminTimezone,
|
||||||
|
fetchCreateMember,
|
||||||
|
fetchAdminResetPassword,
|
||||||
type AdminMember,
|
type AdminMember,
|
||||||
type AdminCalendar,
|
type AdminCalendar,
|
||||||
} from '../api/client.js';
|
} from '../api/client.js';
|
||||||
@@ -59,6 +61,19 @@ export function AdminPage() {
|
|||||||
const [sheetMember, setSheetMember] = useState<AdminMember | null>(null);
|
const [sheetMember, setSheetMember] = useState<AdminMember | null>(null);
|
||||||
const triggerRef = useRef<HTMLButtonElement>(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
|
// Shared calendar picker state
|
||||||
const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null);
|
const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null);
|
||||||
|
|
||||||
@@ -179,6 +194,53 @@ export function AdminPage() {
|
|||||||
setSheetOpen(true);
|
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 =
|
const saveDisabled =
|
||||||
sharedCalMutation.isPending ||
|
sharedCalMutation.isPending ||
|
||||||
effectiveSelected === null ||
|
effectiveSelected === null ||
|
||||||
@@ -250,6 +312,12 @@ export function AdminPage() {
|
|||||||
member={member}
|
member={member}
|
||||||
colorIndex={idx}
|
colorIndex={idx}
|
||||||
onAction={(buttonRef) => openSheet(member, buttonRef)}
|
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>
|
</div>
|
||||||
@@ -620,6 +688,230 @@ export function AdminPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Credential sheet — admin-rotate or admin-add */}
|
{/* Credential sheet — admin-rotate or admin-add */}
|
||||||
@@ -633,6 +925,21 @@ export function AdminPage() {
|
|||||||
triggerRef={triggerRef}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -643,10 +950,12 @@ interface MemberRowProps {
|
|||||||
member: AdminMember;
|
member: AdminMember;
|
||||||
colorIndex: number;
|
colorIndex: number;
|
||||||
onAction: (buttonRef: React.RefObject<HTMLButtonElement | null>) => void;
|
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 buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const resetBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -731,7 +1040,9 @@ function MemberRow({ member, colorIndex, onAction }: MemberRowProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action button */}
|
{/* Action button row */}
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--space-2, 8px)', flexShrink: 0 }}>
|
||||||
|
{/* Credential rotate/add button */}
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -748,11 +1059,35 @@ function MemberRow({ member, colorIndex, onAction }: MemberRowProps) {
|
|||||||
minWidth: '44px',
|
minWidth: '44px',
|
||||||
padding: '0 var(--space-3, 12px)',
|
padding: '0 var(--space-3, 12px)',
|
||||||
fontFamily: 'var(--font-family-base)',
|
fontFamily: 'var(--font-family-base)',
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{member.hasCredential ? 'Rotate' : 'Add credential'}
|
{member.hasCredential ? 'Rotate' : 'Add credential'}
|
||||||
</button>
|
</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>
|
</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 ─────────────────────────────────────────────────────
|
// ── EmptyCalendarsState ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function 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-weight: 600;
|
||||||
--text-display-line-height: 1.2;
|
--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)
|
* BREAKPOINTS (reference; use in @media queries)
|
||||||
* ───────────────────────────────────────────────────────────────────────── */
|
* ───────────────────────────────────────────────────────────────────────── */
|
||||||
|
|||||||
Reference in New Issue
Block a user