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:
@@ -23,15 +23,17 @@
|
||||
* Security: client isAdmin gate is UX only. Server 403 is the real boundary (D-03).
|
||||
*/
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
fetchAdminMembers,
|
||||
fetchAdminCalendars,
|
||||
setSharedCalendar,
|
||||
fetchAdminTimezone,
|
||||
setAdminTimezone,
|
||||
fetchCreateMember,
|
||||
fetchAdminResetPassword,
|
||||
type AdminMember,
|
||||
type AdminCalendar,
|
||||
} from '../api/client.js';
|
||||
@@ -59,6 +61,19 @@ export function AdminPage() {
|
||||
const [sheetMember, setSheetMember] = useState<AdminMember | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Reset-password sheet state (Surface 11B)
|
||||
const [resetSheetOpen, setResetSheetOpen] = useState(false);
|
||||
const [resetTargetMember, setResetTargetMember] = useState<AdminMember | null>(null);
|
||||
// resetTriggerRef: stores the exact button that opened the reset sheet so focus can return on close
|
||||
const resetTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
// Create-member form state (Surface 11A)
|
||||
const [createDisplayName, setCreateDisplayName] = useState('');
|
||||
const [createUsername, setCreateUsername] = useState('');
|
||||
const [createPassword, setCreatePassword] = useState('');
|
||||
const [createConfirmPassword, setCreateConfirmPassword] = useState('');
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
// Shared calendar picker state
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null);
|
||||
|
||||
@@ -179,6 +194,53 @@ export function AdminPage() {
|
||||
setSheetOpen(true);
|
||||
}
|
||||
|
||||
// Create-member mutation (Surface 11A)
|
||||
const createMemberMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Client-side validation (server also validates; this is for UX)
|
||||
if (createPassword !== createConfirmPassword) {
|
||||
throw new Error('mismatch');
|
||||
}
|
||||
if (createPassword.length < 8) {
|
||||
throw new Error('short');
|
||||
}
|
||||
await fetchCreateMember({
|
||||
displayName: createDisplayName.trim(),
|
||||
username: createUsername.trim(),
|
||||
password: createPassword,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Clear form + refresh member list
|
||||
setCreateDisplayName('');
|
||||
setCreateUsername('');
|
||||
setCreatePassword('');
|
||||
setCreateConfirmPassword('');
|
||||
setCreateError(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = err instanceof Error ? err.message : 'server';
|
||||
if (msg === 'mismatch') {
|
||||
setCreateError('Passwords do not match.');
|
||||
} else if (msg === 'short') {
|
||||
setCreateError('Password is too short. Use at least 8 characters.');
|
||||
} else if (msg === 'conflict' || msg.includes('409')) {
|
||||
setCreateError('That username is already in use. Choose a different one.');
|
||||
} else {
|
||||
setCreateError('Something went wrong. Please try again.');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const createSubmitDisabled =
|
||||
createMemberMutation.isPending ||
|
||||
createDisplayName.trim().length === 0 ||
|
||||
createUsername.trim().length === 0 ||
|
||||
createPassword.length === 0 ||
|
||||
createConfirmPassword.length === 0;
|
||||
|
||||
const saveDisabled =
|
||||
sharedCalMutation.isPending ||
|
||||
effectiveSelected === null ||
|
||||
@@ -250,6 +312,12 @@ export function AdminPage() {
|
||||
member={member}
|
||||
colorIndex={idx}
|
||||
onAction={(buttonRef) => openSheet(member, buttonRef)}
|
||||
onResetPassword={(buttonRef) => {
|
||||
// Capture trigger button so focus can return on close
|
||||
resetTriggerRef.current = buttonRef.current;
|
||||
setResetTargetMember(member);
|
||||
setResetSheetOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -620,6 +688,230 @@ export function AdminPage() {
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
{/* ── LOCAL ACCOUNTS section ──────────────────────────────────────── */}
|
||||
<section aria-label="Local Accounts" style={{ marginBottom: 'var(--space-8, 32px)' }}>
|
||||
<div style={sectionLabelStyle}>Local Accounts</div>
|
||||
|
||||
{/* Surface 11A — Add member inline form */}
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--color-border-subtle, var(--color-border))',
|
||||
borderRadius: '8px',
|
||||
padding: 'var(--space-4, 16px)',
|
||||
marginBottom: 'var(--space-6, 24px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-4, 16px)',
|
||||
}}
|
||||
>
|
||||
Add member
|
||||
</div>
|
||||
|
||||
{/* Display name */}
|
||||
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
||||
<label
|
||||
htmlFor="admin-create-display-name"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Display name
|
||||
</label>
|
||||
<input
|
||||
id="admin-create-display-name"
|
||||
type="text"
|
||||
value={createDisplayName}
|
||||
onChange={(e) => setCreateDisplayName(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
||||
<label
|
||||
htmlFor="admin-create-username"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="admin-create-username"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
value={createUsername}
|
||||
onChange={(e) => setCreateUsername(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Initial password */}
|
||||
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
||||
<label
|
||||
htmlFor="admin-create-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Initial password
|
||||
</label>
|
||||
<input
|
||||
id="admin-create-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={createPassword}
|
||||
onChange={(e) => setCreatePassword(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confirm password */}
|
||||
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
|
||||
<label
|
||||
htmlFor="admin-create-confirm-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="admin-create-confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={createConfirmPassword}
|
||||
onChange={(e) => setCreateConfirmPassword(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Inline error */}
|
||||
{createError && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-destructive)',
|
||||
marginBottom: 'var(--space-3, 12px)',
|
||||
}}
|
||||
>
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action row */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={createSubmitDisabled}
|
||||
onClick={() => {
|
||||
setCreateError(null);
|
||||
createMemberMutation.mutate();
|
||||
}}
|
||||
style={{
|
||||
background: createSubmitDisabled
|
||||
? 'var(--color-border, #e2e4e9)'
|
||||
: 'var(--color-member-0, #4a90d9)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
cursor: createSubmitDisabled ? 'default' : 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-6, 24px)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
transition: 'background 0.15s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2, 8px)',
|
||||
}}
|
||||
>
|
||||
{createMemberMutation.isPending && (
|
||||
<Loader2
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
style={{ animation: 'spin 1s linear infinite', flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
Add member
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Credential sheet — admin-rotate or admin-add */}
|
||||
@@ -633,6 +925,21 @@ export function AdminPage() {
|
||||
triggerRef={triggerRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Surface 11B — Reset password sheet */}
|
||||
{resetTargetMember && (
|
||||
<ResetPasswordSheet
|
||||
isOpen={resetSheetOpen}
|
||||
onClose={() => {
|
||||
setResetSheetOpen(false);
|
||||
// Return focus to trigger
|
||||
if (resetTriggerRef.current) {
|
||||
resetTriggerRef.current.focus();
|
||||
}
|
||||
}}
|
||||
member={resetTargetMember}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -643,10 +950,12 @@ interface MemberRowProps {
|
||||
member: AdminMember;
|
||||
colorIndex: number;
|
||||
onAction: (buttonRef: React.RefObject<HTMLButtonElement | null>) => void;
|
||||
onResetPassword?: (buttonRef: React.RefObject<HTMLButtonElement | null>) => void;
|
||||
}
|
||||
|
||||
function MemberRow({ member, colorIndex, onAction }: MemberRowProps) {
|
||||
function MemberRow({ member, colorIndex, onAction, onResetPassword }: MemberRowProps) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const resetBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -731,28 +1040,54 @@ function MemberRow({ member, colorIndex, onAction }: MemberRowProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => onAction(buttonRef)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-3, 12px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{member.hasCredential ? 'Rotate' : 'Add credential'}
|
||||
</button>
|
||||
{/* Action button row */}
|
||||
<div style={{ display: 'flex', gap: 'var(--space-2, 8px)', flexShrink: 0 }}>
|
||||
{/* Credential rotate/add button */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => onAction(buttonRef)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-3, 12px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
{member.hasCredential ? 'Rotate' : 'Add credential'}
|
||||
</button>
|
||||
|
||||
{/* Surface 11B — Reset password button (only for members with a local credential) */}
|
||||
{member.hasLocalCredential && onResetPassword && (
|
||||
<button
|
||||
ref={resetBtnRef}
|
||||
type="button"
|
||||
onClick={() => onResetPassword(resetBtnRef)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-3, 12px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
Reset password
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -842,6 +1177,285 @@ function CalendarRadioRow({ calendar, isSelected, onSelect }: CalendarRadioRowPr
|
||||
);
|
||||
}
|
||||
|
||||
// ── ResetPasswordSheet ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Surface 11B — Admin password reset sheet.
|
||||
* Opens as a bottom sheet (mobile) / centered modal (desktop).
|
||||
* Pattern: CredentialSheet (role=dialog, aria-modal, Escape closes, focus returns to trigger).
|
||||
* No current-password field — admin reset does not require knowing the old password.
|
||||
*/
|
||||
|
||||
interface ResetPasswordSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
member: AdminMember;
|
||||
}
|
||||
|
||||
function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps) {
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const headingRef = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
// Escape closes the sheet
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Focus heading on open
|
||||
useEffect(() => {
|
||||
if (isOpen && headingRef.current) {
|
||||
headingRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
function handleClose() {
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError(null);
|
||||
onClose();
|
||||
}
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (newPassword !== confirmPassword) throw new Error('mismatch');
|
||||
await fetchAdminResetPassword(member.id, newPassword);
|
||||
},
|
||||
onSuccess: () => {
|
||||
handleClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = err instanceof Error ? err.message : 'server';
|
||||
if (msg === 'mismatch') {
|
||||
setError('Passwords do not match.');
|
||||
} else {
|
||||
setError('Something went wrong. Please try again.');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const isPending = resetMutation.isPending;
|
||||
const submitDisabled =
|
||||
isPending || newPassword.length === 0 || confirmPassword.length === 0;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'var(--color-overlay, rgba(0,0,0,0.32))',
|
||||
zIndex: 300,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Reset password"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'var(--color-surface, #ffffff)',
|
||||
borderRadius: '12px 12px 0 0',
|
||||
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
|
||||
padding: 'var(--space-6, 24px)',
|
||||
zIndex: 301,
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
maxWidth: '480px',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
margin: '0 0 var(--space-1, 4px) 0',
|
||||
fontSize: 'var(--text-heading-size, 18px)',
|
||||
fontWeight: 600,
|
||||
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
||||
color: 'var(--color-text-primary)',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
Reset password
|
||||
</h2>
|
||||
|
||||
{/* Member subtitle */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-secondary)',
|
||||
marginBottom: 'var(--space-6, 24px)',
|
||||
}}
|
||||
>
|
||||
{member.displayName ?? 'Member'}
|
||||
</div>
|
||||
|
||||
{/* New password */}
|
||||
<div style={{ marginBottom: 'var(--space-3, 12px)' }}>
|
||||
<label
|
||||
htmlFor="reset-new-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
id="reset-new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: `1px solid ${error ? 'var(--color-destructive)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confirm new password */}
|
||||
<div style={{ marginBottom: 'var(--space-4, 16px)' }}>
|
||||
<label
|
||||
htmlFor="reset-confirm-password"
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Confirm new password
|
||||
</label>
|
||||
<input
|
||||
id="reset-confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
aria-describedby={error ? 'reset-error' : undefined}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||
border: `1px solid ${error ? 'var(--color-destructive)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
color: 'var(--color-text-primary)',
|
||||
background: 'var(--color-surface)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
outline: 'none',
|
||||
minHeight: '44px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Inline error */}
|
||||
{error && (
|
||||
<div
|
||||
id="reset-error"
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-destructive)',
|
||||
marginBottom: 'var(--space-4, 16px)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action row */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 'var(--space-3, 12px)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: isPending ? 'default' : 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-secondary)',
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-4, 16px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitDisabled}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
resetMutation.mutate();
|
||||
}}
|
||||
style={{
|
||||
background: submitDisabled
|
||||
? 'var(--color-border, #e2e4e9)'
|
||||
: 'var(--color-member-0, #4a90d9)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
cursor: submitDisabled ? 'default' : 'pointer',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
minHeight: '44px',
|
||||
minWidth: '44px',
|
||||
padding: '0 var(--space-4, 16px)',
|
||||
borderRadius: 'var(--space-1, 4px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
>
|
||||
Reset password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── EmptyCalendarsState ─────────────────────────────────────────────────────
|
||||
|
||||
function EmptyCalendarsState() {
|
||||
|
||||
Reference in New Issue
Block a user