chore: merge executor worktree (worktree-agent-a12887546a61a8053)
This commit is contained in:
@@ -0,0 +1,124 @@
|
|||||||
|
---
|
||||||
|
phase: 17-ui-optimization-polish
|
||||||
|
plan: "06"
|
||||||
|
subsystem: pwa-admin
|
||||||
|
tags: [admin, toasts, tabs, aria, accessibility, e2e]
|
||||||
|
dependency_graph:
|
||||||
|
requires: [17-01]
|
||||||
|
provides: [admin-success-toasts, admin-two-tab-nav, admin-reset-sheet-centering, admin-e2e-aria]
|
||||||
|
affects: [apps/pwa/src/routes/AdminPage.tsx, apps/pwa/e2e/admin.spec.ts]
|
||||||
|
tech_stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "useState + useEffect auto-dismiss toast pattern (mirrors SyncStateToast lines 72-79)"
|
||||||
|
- "ARIA tablist/tab/tabpanel roving-tabindex pattern (ArrowLeft/ArrowRight keyboard nav)"
|
||||||
|
- "Phone/desktop style branch for dialog centering (translate(-50%,-50%))"
|
||||||
|
- "onSuccess callback prop to propagate success signal from sheet to parent"
|
||||||
|
key_files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- apps/pwa/src/routes/AdminPage.tsx
|
||||||
|
- apps/pwa/e2e/admin.spec.ts
|
||||||
|
decisions:
|
||||||
|
- "Tasks 1 and 2 committed together (same file AdminPage.tsx) — acceptable since both modify the same component"
|
||||||
|
- "Playwright tests verified against worktree Vite (port 5174) since main dev server at 5173 serves main branch code; 12/12 tests pass"
|
||||||
|
- "ResetPasswordSheet receives onSuccess callback prop to fire toast at AdminPage level, avoiding toast rendered inside a closing sheet"
|
||||||
|
- "Section order reorg: MEMBERS + LOCAL ACCOUNTS under members panel; SHARED CALENDAR + TIMEZONE under settings panel — matches UI-SPEC D-10 contents mapping"
|
||||||
|
metrics:
|
||||||
|
duration: "9 minutes"
|
||||||
|
completed: "2026-06-18"
|
||||||
|
tasks_completed: 3
|
||||||
|
tasks_total: 3
|
||||||
|
files_modified: 2
|
||||||
|
status: complete
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 17 Plan 06: Admin Polish — Toasts, Two-Tab Nav, Reset-Sheet Centering Summary
|
||||||
|
|
||||||
|
**One-liner:** Admin UX polish with success toasts (D-08), two-tab ARIA strip wrapping existing sections (D-10), desktop-centered reset-password sheet (D-09 admin slice), and `admin.spec.ts` tab ARIA + keyboard assertions (Wave 0 requirement).
|
||||||
|
|
||||||
|
## Tasks Completed
|
||||||
|
|
||||||
|
| # | Task | Commit | Status |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| 1 | Add success toasts to create-member and reset-password | 620d641 | Done |
|
||||||
|
| 2 | Rework AdminPage into two-tab ARIA strip + center reset-sheet on desktop | 620d641 | Done |
|
||||||
|
| 3 | Add admin.spec.ts tab ARIA + keyboard + toast assertions | 944045c | Done |
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
### Task 1 — Success Toasts (D-08)
|
||||||
|
|
||||||
|
Added a `toast` state (string|null) + 3000ms auto-dismiss `useEffect` to `AdminPage`. The `phone` boolean (`window.matchMedia('(max-width: 767px)').matches`) drives the bottom offset.
|
||||||
|
|
||||||
|
**Hooks:**
|
||||||
|
- `createMemberMutation.onSuccess` → `setToast('Member added.')`
|
||||||
|
- `ResetPasswordSheet.resetMutation.onSuccess` → calls `onSuccess?.()` callback prop → `setToast('Password reset.')` at AdminPage level
|
||||||
|
|
||||||
|
**Toast render:** `role="status"` + `aria-live="polite"` + `aria-atomic="true"`, fixed position, 16px CheckCircle (`var(--color-member-0)`), auto-dismisses after 3000ms. Phone offset: `calc(var(--bottom-chrome-h) + var(--space-4))` to clear BottomTabBar; desktop: `var(--space-6)`.
|
||||||
|
|
||||||
|
### Task 2 — Two-Tab ARIA Strip + Reset-Sheet Centering (D-10 + D-09)
|
||||||
|
|
||||||
|
**Tab strip:** `role="tablist"` div with two `role="tab"` buttons (`admin-tab-members`, `admin-tab-settings`). Roving tabindex (active: 0, inactive: -1). `handleTabKeyDown` implements ArrowRight/ArrowLeft with `querySelector + focus()`. Active tab: fontWeight 600 + `borderBottom: 2px solid var(--color-member-0)`.
|
||||||
|
|
||||||
|
**Section reorg:**
|
||||||
|
- Members panel (`admin-panel-members`): MEMBERS section + LOCAL ACCOUNTS section
|
||||||
|
- Settings panel (`admin-panel-settings`): SHARED CALENDAR section + TIMEZONE section
|
||||||
|
|
||||||
|
**Panel ARIA:** `role="tabpanel"`, `aria-labelledby`, `tabIndex={0}`, `hidden={activeTab !== id}`.
|
||||||
|
|
||||||
|
**Reset-sheet desktop centering:** `sheetPhone` boolean drives phone (bottom-sheet: bottom 0/left 0/right 0/borderRadius 12 12 0 0) vs desktop (position fixed, top 50%/left 50%/transform translate(-50%,-50%)/maxWidth 480px/borderRadius 12px) branch. `role="dialog"` + `aria-modal="true"` + `aria-label` unchanged.
|
||||||
|
|
||||||
|
### Task 3 — admin.spec.ts ARIA + Keyboard Assertions
|
||||||
|
|
||||||
|
Extended `apps/pwa/e2e/admin.spec.ts` with two new `test.describe` blocks:
|
||||||
|
|
||||||
|
**`Admin two-tab ARIA strip (D-10)`** (5 tests):
|
||||||
|
1. tablist + both named tabs visible
|
||||||
|
2. Members & Accounts tab is selected by default (aria-selected=true)
|
||||||
|
3. ArrowRight switches to Settings tab (aria-selected=true)
|
||||||
|
4. ArrowLeft returns to Members & Accounts tab
|
||||||
|
5. Both panels have correct `aria-labelledby`; phone overflow check
|
||||||
|
|
||||||
|
**`Admin success toast structure (D-08)`** (1 test):
|
||||||
|
- `role="status"` not present on initial load (toast is null)
|
||||||
|
|
||||||
|
**Playwright run result:** 12/12 tests pass on pixel profile.
|
||||||
|
|
||||||
|
**Harness note:** Tests were verified against a worktree Vite instance (`port 5174`) because the resident dev server at `5173` serves the main branch (pre-merge). The CI harness at merge time will use the merged code. Verified via `PLAYWRIGHT_BASE_URL=http://localhost:5174`.
|
||||||
|
|
||||||
|
## Playwright-CLI Observation (acceptance criteria §Task 1)
|
||||||
|
|
||||||
|
Playwright snapshot confirmed: tab strip renders correctly on pixel (412×915). Both "Members & Accounts" and "Settings" tabs are visible within the tab strip with no horizontal overflow. ArrowRight correctly moves `aria-selected` to the Settings tab. Toast `role="status"` is absent on initial page load as expected.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-ordering of sections
|
||||||
|
|
||||||
|
The existing `AdminPage.tsx` had sections in order: MEMBERS → SHARED CALENDAR → TIMEZONE → LOCAL ACCOUNTS. The UI-SPEC §D-10 contents mapping assigns MEMBERS + LOCAL ACCOUNTS to the members panel, and SHARED CALENDAR + TIMEZONE to the settings panel. This required reordering: LOCAL ACCOUNTS was moved earlier (now follows MEMBERS in the members panel) and SHARED CALENDAR / TIMEZONE became the settings panel contents. This is a presentation change only — no mutation logic was touched.
|
||||||
|
|
||||||
|
### Tasks 1 + 2 committed together
|
||||||
|
|
||||||
|
Tasks 1 and 2 both modify `apps/pwa/src/routes/AdminPage.tsx`. Since both changes were made in one editing session on the same file, they were committed together in commit `620d641`. The commit message covers the toast additions; Task 2 changes (tab strip + reset-sheet centering) are described in the commit body.
|
||||||
|
|
||||||
|
### Playwright test port
|
||||||
|
|
||||||
|
The plan's verification command `pnpm --filter @familysync/pwa exec playwright test --project=pixel admin.spec.ts` requires `PLAYWRIGHT_BASE_URL` pointing to a server serving the updated code. The resident dev server at port 5173 serves the main branch. A temporary worktree Vite at port 5174 was started to execute the verification. 12/12 tests passed. CI will run against merged code where this is a non-issue.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None. All toast copy is hardcoded string literals; all ARIA roles are present in the rendered JSX.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
None. No new trust boundaries, network endpoints, or authorization logic introduced. Toast content is hardcoded; tab state is local `useState`; server-side 403 enforcement on `/api/admin/*` is unchanged per T-17-06-02.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
| Item | Result |
|
||||||
|
|------|--------|
|
||||||
|
| 17-06-SUMMARY.md | FOUND |
|
||||||
|
| apps/pwa/src/routes/AdminPage.tsx | FOUND |
|
||||||
|
| apps/pwa/e2e/admin.spec.ts | FOUND |
|
||||||
|
| Commit 620d641 (toasts + two-tab nav) | FOUND |
|
||||||
|
| Commit 944045c (admin.spec.ts) | FOUND |
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* admin.spec.ts — Phase 10 Plan 04 admin route gate
|
* admin.spec.ts — Phase 10 Plan 04 admin route gate + Phase 17 Plan 06 two-tab ARIA
|
||||||
*
|
*
|
||||||
* Tests:
|
* Tests:
|
||||||
* - Admin user (id=1, seeded is_admin=true by global-setup) sees the Admin nav entry
|
* - Admin user (id=1, seeded is_admin=true by global-setup) sees the Admin nav entry
|
||||||
* and reaches /admin with "Admin Settings" heading + Members section.
|
* and reaches /admin with "Admin Settings" heading + Members section.
|
||||||
* - Non-admin (route-mocked isAdmin:false) does NOT see the Admin nav entry and is
|
* - Non-admin (route-mocked isAdmin:false) does NOT see the Admin nav entry and is
|
||||||
* redirected from /admin to /calendar.
|
* redirected from /admin to /calendar.
|
||||||
|
* - Phase 17 (D-10): Two-tab ARIA strip — tablist + named tabs visible, ArrowRight
|
||||||
|
* moves selection to the Settings tab (keyboard nav).
|
||||||
|
* - Phase 17 (D-08): Success toast — role=status element structure present.
|
||||||
*
|
*
|
||||||
* Requires the dev stack running with DEV_AUTH_BYPASS=true (see e2e/README.md).
|
* Requires the dev stack running with DEV_AUTH_BYPASS=true (see e2e/README.md).
|
||||||
* global-setup seeds: users id=1 is_admin=true (Plan 10-01 note).
|
* global-setup seeds: users id=1 is_admin=true (Plan 10-01 note).
|
||||||
@@ -105,3 +108,84 @@ test.describe('Non-admin user — admin nav entry hidden + /admin redirect', ()
|
|||||||
await expect(page.getByRole('heading', { name: 'Admin Settings' })).toHaveCount(0);
|
await expect(page.getByRole('heading', { name: 'Admin Settings' })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Phase 17 D-10: Two-tab ARIA strip ────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Admin two-tab ARIA strip (D-10)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/admin');
|
||||||
|
// Wait for the heading to confirm /admin loaded (admin session via DEV_AUTH_BYPASS)
|
||||||
|
await expect(page.getByRole('heading', { name: 'Admin Settings' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tab strip has correct ARIA roles — tablist and both named tabs visible', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await expect(page.getByRole('tablist')).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Members & Accounts' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Settings' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Members & Accounts tab is selected by default', async ({ page }) => {
|
||||||
|
const membersTab = page.getByRole('tab', { name: 'Members & Accounts' });
|
||||||
|
await expect(membersTab).toHaveAttribute('aria-selected', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ArrowRight switches selection to the Settings tab', async ({ page }) => {
|
||||||
|
const membersTab = page.getByRole('tab', { name: 'Members & Accounts' });
|
||||||
|
const settingsTab = page.getByRole('tab', { name: 'Settings' });
|
||||||
|
await membersTab.focus();
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await expect(settingsTab).toHaveAttribute('aria-selected', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ArrowLeft from Settings tab switches back to Members & Accounts tab', async ({ page }) => {
|
||||||
|
const membersTab = page.getByRole('tab', { name: 'Members & Accounts' });
|
||||||
|
const settingsTab = page.getByRole('tab', { name: 'Settings' });
|
||||||
|
// Navigate to Settings first
|
||||||
|
await membersTab.focus();
|
||||||
|
await page.keyboard.press('ArrowRight');
|
||||||
|
await expect(settingsTab).toHaveAttribute('aria-selected', 'true');
|
||||||
|
// Then go back
|
||||||
|
await page.keyboard.press('ArrowLeft');
|
||||||
|
await expect(membersTab).toHaveAttribute('aria-selected', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('both tab panels exist with correct ARIA labelledby', async ({ page }) => {
|
||||||
|
// Both panels are in the DOM; the inactive one uses the HTML `hidden` attribute
|
||||||
|
const membersPanel = page.locator('#admin-panel-members');
|
||||||
|
const settingsPanel = page.locator('#admin-panel-settings');
|
||||||
|
await expect(membersPanel).toHaveAttribute('aria-labelledby', 'admin-tab-members');
|
||||||
|
await expect(settingsPanel).toHaveAttribute('aria-labelledby', 'admin-tab-settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tab strip fits without horizontal overflow on phone viewport (390px)', async ({
|
||||||
|
page,
|
||||||
|
viewport,
|
||||||
|
}) => {
|
||||||
|
// Only meaningful on narrow viewports; skip on desktop
|
||||||
|
if ((viewport?.width ?? 1280) >= 768) return;
|
||||||
|
const tablist = page.getByRole('tablist');
|
||||||
|
// Verify both tabs are visible (no overflow clipping them)
|
||||||
|
await expect(page.getByRole('tab', { name: 'Members & Accounts' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Settings' })).toBeVisible();
|
||||||
|
// scrollWidth <= clientWidth (no horizontal overflow)
|
||||||
|
const overflows = await tablist.evaluate((el) => el.scrollWidth > el.clientWidth);
|
||||||
|
expect(overflows, 'Tab strip must not overflow horizontally').toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Phase 17 D-08: Success toast structure ───────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Admin success toast structure (D-08)', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/admin');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Admin Settings' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('role=status live region is not present when no toast is active', async ({ page }) => {
|
||||||
|
// On page load no toast should be showing
|
||||||
|
// The toast is rendered conditionally only when toast !== null
|
||||||
|
await expect(page.locator('[role="status"]')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+394
-220
@@ -55,6 +55,22 @@ const sectionLabelStyle: React.CSSProperties = {
|
|||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Phone detection for toast bottom offset
|
||||||
|
const phone = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
||||||
|
|
||||||
|
// Success toast state (D-08)
|
||||||
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Auto-dismiss toast after 3000ms — mirrors SyncStateToast lines 72-79
|
||||||
|
useEffect(() => {
|
||||||
|
if (!toast) return;
|
||||||
|
const timer = setTimeout(() => setToast(null), 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
// Two-tab navigation state (D-10)
|
||||||
|
const [activeTab, setActiveTab] = useState<'members' | 'settings'>('members');
|
||||||
|
|
||||||
// Credential sheet state
|
// Credential sheet state
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
const [sheetMode, setSheetMode] = useState<CredentialSheetMode>('admin-add');
|
const [sheetMode, setSheetMode] = useState<CredentialSheetMode>('admin-add');
|
||||||
@@ -185,6 +201,29 @@ export function AdminPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Roving tabindex keyboard handler for the two-tab strip (D-10)
|
||||||
|
function handleTabKeyDown(e: React.KeyboardEvent, current: 'members' | 'settings') {
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = current === 'members' ? 'settings' : 'members';
|
||||||
|
setActiveTab(next);
|
||||||
|
(
|
||||||
|
e.currentTarget.parentElement?.querySelector(
|
||||||
|
`[id="admin-tab-${next}"]`,
|
||||||
|
) as HTMLElement | null
|
||||||
|
)?.focus();
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
const prev = current === 'settings' ? 'members' : 'settings';
|
||||||
|
setActiveTab(prev);
|
||||||
|
(
|
||||||
|
e.currentTarget.parentElement?.querySelector(
|
||||||
|
`[id="admin-tab-${prev}"]`,
|
||||||
|
) as HTMLElement | null
|
||||||
|
)?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Open credential sheet for a member
|
// Open credential sheet for a member
|
||||||
function openSheet(member: AdminMember, buttonRef: React.RefObject<HTMLButtonElement | null>) {
|
function openSheet(member: AdminMember, buttonRef: React.RefObject<HTMLButtonElement | null>) {
|
||||||
// Capture the button so focus can return on close
|
// Capture the button so focus can return on close
|
||||||
@@ -219,6 +258,7 @@ export function AdminPage() {
|
|||||||
setCreateError(null);
|
setCreateError(null);
|
||||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||||
|
setToast('Member added.');
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
const msg = err instanceof Error ? err.message : 'server';
|
const msg = err instanceof Error ? err.message : 'server';
|
||||||
@@ -266,7 +306,7 @@ export function AdminPage() {
|
|||||||
{/* Page heading */}
|
{/* Page heading */}
|
||||||
<h1
|
<h1
|
||||||
style={{
|
style={{
|
||||||
margin: '0 0 var(--space-8, 32px) 0',
|
margin: '0 0 var(--space-6, 24px) 0',
|
||||||
fontSize: 'var(--text-heading-size, 18px)',
|
fontSize: 'var(--text-heading-size, 18px)',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
||||||
@@ -276,7 +316,60 @@ export function AdminPage() {
|
|||||||
Admin Settings
|
Admin Settings
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* ── MEMBERS section ─────────────────────────────────────────────── */}
|
{/* ── Two-tab strip (D-10) ──────────────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
borderBottom: '1px solid var(--color-border-subtle, var(--color-border))',
|
||||||
|
marginBottom: 'var(--space-6, 24px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(['members', 'settings'] as const).map((id) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
role="tab"
|
||||||
|
id={`admin-tab-${id}`}
|
||||||
|
aria-selected={activeTab === id}
|
||||||
|
aria-controls={`admin-panel-${id}`}
|
||||||
|
tabIndex={activeTab === id ? 0 : -1}
|
||||||
|
onClick={() => setActiveTab(id)}
|
||||||
|
onKeyDown={(e) => handleTabKeyDown(e, id)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||||
|
minHeight: '44px',
|
||||||
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
|
fontWeight: activeTab === id ? 600 : 400,
|
||||||
|
color:
|
||||||
|
activeTab === id
|
||||||
|
? 'var(--color-text-primary)'
|
||||||
|
: 'var(--color-text-secondary)',
|
||||||
|
borderBottom:
|
||||||
|
activeTab === id
|
||||||
|
? '2px solid var(--color-member-0)'
|
||||||
|
: '2px solid transparent',
|
||||||
|
transition: 'color 0.1s ease, border-color 0.1s ease',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{id === 'members' ? 'Members & Accounts' : 'Settings'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Tab panel: Members & Accounts ────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
id="admin-panel-members"
|
||||||
|
aria-labelledby="admin-tab-members"
|
||||||
|
tabIndex={0}
|
||||||
|
hidden={activeTab !== 'members'}
|
||||||
|
>
|
||||||
|
|
||||||
|
{/* ── MEMBERS section ───────────────────────────────────────────── */}
|
||||||
<section aria-label="Members" style={{ marginBottom: 'var(--space-8, 32px)' }}>
|
<section aria-label="Members" style={{ marginBottom: 'var(--space-8, 32px)' }}>
|
||||||
<div style={sectionLabelStyle}>Members</div>
|
<div style={sectionLabelStyle}>Members</div>
|
||||||
|
|
||||||
@@ -324,6 +417,242 @@ 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>{/* end admin-panel-members */}
|
||||||
|
|
||||||
|
{/* ── Tab panel: Settings ───────────────────────────────────────────── */}
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
id="admin-panel-settings"
|
||||||
|
aria-labelledby="admin-tab-settings"
|
||||||
|
tabIndex={0}
|
||||||
|
hidden={activeTab !== 'settings'}
|
||||||
|
>
|
||||||
|
|
||||||
{/* ── SHARED CALENDAR section ──────────────────────────────────────── */}
|
{/* ── SHARED CALENDAR section ──────────────────────────────────────── */}
|
||||||
<section aria-label="Shared Calendar" style={{ marginBottom: 'var(--space-8, 32px)' }}>
|
<section aria-label="Shared Calendar" style={{ marginBottom: 'var(--space-8, 32px)' }}>
|
||||||
<div style={sectionLabelStyle}>Shared Calendar</div>
|
<div style={sectionLabelStyle}>Shared Calendar</div>
|
||||||
@@ -605,7 +934,9 @@ export function AdminPage() {
|
|||||||
color: 'var(--color-text-primary)',
|
color: 'var(--color-text-primary)',
|
||||||
borderRadius: 'var(--space-1, 4px)',
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
background: active ? 'var(--color-member-0, #4A90D9)' : 'transparent',
|
background: active
|
||||||
|
? 'var(--color-member-0, #4A90D9)'
|
||||||
|
: 'transparent',
|
||||||
...(active ? { color: '#ffffff' } : null),
|
...(active ? { color: '#ffffff' } : null),
|
||||||
minHeight: '44px',
|
minHeight: '44px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -688,231 +1019,50 @@ 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>{/* end admin-panel-settings */}
|
||||||
|
|
||||||
|
</div>{/* end centered content column */}
|
||||||
|
|
||||||
|
{/* ── Success toast (D-08) ──────────────────────────────────────────────── */}
|
||||||
|
{toast && (
|
||||||
<div
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--color-border-subtle, var(--color-border))',
|
position: 'fixed',
|
||||||
borderRadius: '8px',
|
bottom: phone
|
||||||
padding: 'var(--space-4, 16px)',
|
? 'calc(var(--bottom-chrome-h) + var(--space-4, 16px))'
|
||||||
marginBottom: 'var(--space-6, 24px)',
|
: 'var(--space-6, 24px)',
|
||||||
}}
|
left: '50%',
|
||||||
>
|
transform: 'translateX(-50%)',
|
||||||
<div
|
zIndex: 300,
|
||||||
style={{
|
background: 'var(--color-surface-raised, #ffffff)',
|
||||||
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)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 'var(--space-1, 4px)',
|
borderRadius: 'var(--space-2, 8px)',
|
||||||
fontSize: 'var(--text-body-size, 15px)',
|
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||||
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)',
|
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',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 'var(--space-2, 8px)',
|
gap: 'var(--space-2, 8px)',
|
||||||
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
|
fontWeight: 'var(--text-label-weight, 400)' as React.CSSProperties['fontWeight'],
|
||||||
|
lineHeight: 'var(--text-label-line-height, 1.4)',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
whiteSpace: 'nowrap' as React.CSSProperties['whiteSpace'],
|
||||||
|
maxWidth: '90vw',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{createMemberMutation.isPending && (
|
<CheckCircle
|
||||||
<Loader2
|
size={16}
|
||||||
size={14}
|
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
|
style={{ color: 'var(--color-member-0)', flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
|
<span>{toast}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
Add member
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Credential sheet — admin-rotate or admin-add */}
|
{/* Credential sheet — admin-rotate or admin-add */}
|
||||||
{sheetMember && (
|
{sheetMember && (
|
||||||
@@ -937,6 +1087,7 @@ export function AdminPage() {
|
|||||||
resetTriggerRef.current.focus();
|
resetTriggerRef.current.focus();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onSuccess={() => setToast('Password reset.')}
|
||||||
member={resetTargetMember}
|
member={resetTargetMember}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1189,10 +1340,11 @@ function CalendarRadioRow({ calendar, isSelected, onSelect }: CalendarRadioRowPr
|
|||||||
interface ResetPasswordSheetProps {
|
interface ResetPasswordSheetProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
member: AdminMember;
|
member: AdminMember;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps) {
|
function ResetPasswordSheet({ isOpen, onClose, onSuccess, member }: ResetPasswordSheetProps) {
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -1222,6 +1374,10 @@ function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phone detection for desktop centering
|
||||||
|
const sheetPhone =
|
||||||
|
typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
||||||
|
|
||||||
const resetMutation = useMutation({
|
const resetMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (newPassword !== confirmPassword) throw new Error('mismatch');
|
if (newPassword !== confirmPassword) throw new Error('mismatch');
|
||||||
@@ -1229,6 +1385,7 @@ function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
handleClose();
|
handleClose();
|
||||||
|
onSuccess?.();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
const msg = err instanceof Error ? err.message : 'server';
|
const msg = err instanceof Error ? err.message : 'server';
|
||||||
@@ -1259,12 +1416,14 @@ function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sheet */}
|
{/* Sheet — phone: bottom-sheet / desktop: centered modal (D-09) */}
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="Reset password"
|
aria-label="Reset password"
|
||||||
style={{
|
style={
|
||||||
|
sheetPhone
|
||||||
|
? {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -1275,9 +1434,24 @@ function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps
|
|||||||
padding: 'var(--space-6, 24px)',
|
padding: 'var(--space-6, 24px)',
|
||||||
zIndex: 301,
|
zIndex: 301,
|
||||||
fontFamily: 'var(--font-family-base)',
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
position: 'fixed',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
maxWidth: '480px',
|
maxWidth: '480px',
|
||||||
margin: '0 auto',
|
width: 'calc(100% - var(--space-8, 32px))',
|
||||||
}}
|
maxHeight: 'calc(100dvh - var(--space-8, 32px))',
|
||||||
|
overflowY: 'auto',
|
||||||
|
background: 'var(--color-surface, #ffffff)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
|
||||||
|
padding: 'var(--space-6, 24px)',
|
||||||
|
zIndex: 301,
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
ref={headingRef}
|
ref={headingRef}
|
||||||
|
|||||||
Reference in New Issue
Block a user