feat(19-04): AdminPage LOCAL ACCOUNTS + SettingsSheet change-password / link-OIDC
- Add hasLocalCredential to AdminMember type (mirrors API extension from plan 19-02) - Add createMember mutation + Surface 11A inline add-member form in AdminPage - Add Surface 11B Reset-password button in MemberRow (hasLocalCredential gate) - Add ResetPasswordSheet component (bottom-sheet, role=dialog, focus-managed, Escape closes) - Add Surface 12 Change-password row in SettingsSheet (hasLocalCredential gate) - Add Surface 13 Link-OIDC identity row in SettingsSheet (hasLocalCredential + oidcEnabled gate) - Add ChangePasswordSheet component (current/new/confirm fields, change-password mutation) - Add LinkOidcSheet component (confirmation dialog; uses generic OIDC copy per D-06, no provider branding) - Fix: update InstructionSheet.test.tsx to wrap with QueryClientProvider (Rule 1 - now uses useQuery) - Fix: remove stale eslint-disable in App.test.tsx (lint --max-warnings 0 would fail) - All 263 tests pass; typecheck clean; lint clean
This commit is contained in:
@@ -125,7 +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; // eslint-disable-line @typescript-eslint/no-unused-vars
|
const _mockFetchAuthMode = fetchAuthMode as Mock;
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -543,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 {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user