feat(10-04): AdminPage + /admin route + conditional nav entries + e2e spec
- AdminPage: Admin Settings heading, MEMBERS section (avatar+status+action), SHARED CALENDAR radio group + two-tap Save + empty state - App.tsx: /admin route gated by meQuery.data.user.isAdmin (loading gate prevents flash), SetupBanner mounted above content, BottomTabBar + AppNav receive isAdmin - AppNav.tsx: ShieldCheck Admin nav entry rendered only when isAdmin=true (D-03 UX gating) - BottomTabBar.tsx: ShieldCheck Admin tab rendered only when isAdmin=true (D-03 UX gating) - e2e/admin.spec.ts: 5 assertions across 3 profiles (15 total tests) — admin sees nav+page+members, non-admin: no nav entry + /admin redirects to /calendar - All 15 e2e tests pass (iphone/pixel/desktop); production build clean
This commit is contained in:
@@ -0,0 +1,544 @@
|
||||
/**
|
||||
* AdminPage — /admin route (D-02: dedicated gated route, not SettingsSheet extension).
|
||||
*
|
||||
* Non-admin users are redirected to /calendar at the App.tsx route level (UX, D-03).
|
||||
* The server enforces 403 on every /api/admin/* request (Plan 03, requireAdmin).
|
||||
*
|
||||
* UI-SPEC §Surface 1 (/admin route):
|
||||
* - "Admin Settings" heading (18px/600)
|
||||
* - Centered content column, maxWidth 640px on desktop
|
||||
* - var(--space-12) top/bottom padding, var(--space-6) horizontal padding
|
||||
*
|
||||
* UI-SPEC §Surface 2 (MEMBERS section):
|
||||
* - 32px avatar swatch (var(--color-member-N)) + member name + credential status badge
|
||||
* - "Rotate" or "Add credential" action button per hasCredential
|
||||
* - Opens CredentialSheet in admin-rotate or admin-add mode
|
||||
*
|
||||
* UI-SPEC §Surface 5 (SHARED CALENDAR section):
|
||||
* - Radio group, one row per synced calendar
|
||||
* - "Currently shared" label on active selection
|
||||
* - Two-tap Save (disabled until selection differs from saved)
|
||||
* - Empty state when no calendars synced
|
||||
*
|
||||
* Security: client isAdmin gate is UX only. Server 403 is the real boundary (D-03).
|
||||
*/
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
fetchAdminMembers,
|
||||
fetchAdminCalendars,
|
||||
setSharedCalendar,
|
||||
type AdminMember,
|
||||
type AdminCalendar,
|
||||
} from '../api/client.js';
|
||||
import { CredentialSheet, type CredentialSheetMode } from '../components/CredentialSheet.js';
|
||||
|
||||
// ── Styles ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const sectionLabelStyle: React.CSSProperties = {
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
marginBottom: 'var(--space-2, 8px)',
|
||||
};
|
||||
|
||||
// ── AdminPage ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function AdminPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Credential sheet state
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [sheetMode, setSheetMode] = useState<CredentialSheetMode>('admin-add');
|
||||
const [sheetMember, setSheetMember] = useState<AdminMember | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Shared calendar picker state
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState<number | null>(null);
|
||||
|
||||
// Members query
|
||||
const membersQuery = useQuery({
|
||||
queryKey: ['admin', 'members'],
|
||||
queryFn: fetchAdminMembers,
|
||||
retry: false,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
// Calendars query
|
||||
const calendarsQuery = useQuery({
|
||||
queryKey: ['admin', 'calendars'],
|
||||
queryFn: fetchAdminCalendars,
|
||||
retry: false,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
// Derive current saved shared calendar id from the data
|
||||
const currentSharedId =
|
||||
calendarsQuery.data?.calendars.find((c) => c.isShared)?.id ?? null;
|
||||
|
||||
// Effective selected = user pick OR fallback to current saved
|
||||
const effectiveSelected = selectedCalendarId ?? currentSharedId;
|
||||
|
||||
// Save shared calendar mutation
|
||||
const sharedCalMutation = useMutation({
|
||||
mutationFn: (calId: number) => setSharedCalendar(calId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'calendars'] });
|
||||
// Also invalidate events so the shared lane updates
|
||||
void queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||
setSelectedCalendarId(null); // reset picker
|
||||
},
|
||||
});
|
||||
|
||||
// Open credential sheet for a member
|
||||
function openSheet(member: AdminMember, buttonRef: React.RefObject<HTMLButtonElement | null>) {
|
||||
// Capture the button so focus can return on close
|
||||
(triggerRef as React.MutableRefObject<HTMLElement | null>).current =
|
||||
buttonRef.current;
|
||||
setSheetMember(member);
|
||||
setSheetMode(member.hasCredential ? 'admin-rotate' : 'admin-add');
|
||||
setSheetOpen(true);
|
||||
}
|
||||
|
||||
const saveDisabled =
|
||||
sharedCalMutation.isPending ||
|
||||
effectiveSelected === null ||
|
||||
effectiveSelected === currentSharedId;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
// Bottom padding to clear the 56px fixed tab bar on phone
|
||||
paddingBottom: 'calc(56px + env(safe-area-inset-bottom, 0px))',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '640px',
|
||||
margin: '0 auto',
|
||||
padding: 'var(--space-12, 48px) var(--space-6, 24px)',
|
||||
}}
|
||||
>
|
||||
{/* Page heading */}
|
||||
<h1
|
||||
style={{
|
||||
margin: '0 0 var(--space-8, 32px) 0',
|
||||
fontSize: 'var(--text-heading-size, 18px)',
|
||||
fontWeight: 600,
|
||||
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
Admin Settings
|
||||
</h1>
|
||||
|
||||
{/* ── MEMBERS section ─────────────────────────────────────────────── */}
|
||||
<section aria-label="Members" style={{ marginBottom: 'var(--space-8, 32px)' }}>
|
||||
<div style={sectionLabelStyle}>Members</div>
|
||||
|
||||
{membersQuery.isLoading && (
|
||||
<div
|
||||
style={{
|
||||
padding: 'var(--space-4, 16px) 0',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
}}
|
||||
>
|
||||
Loading members…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{membersQuery.isError && (
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--color-destructive)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
padding: 'var(--space-4, 16px) 0',
|
||||
}}
|
||||
>
|
||||
Could not load members.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{membersQuery.data && (
|
||||
<div>
|
||||
{membersQuery.data.members.map((member, idx) => (
|
||||
<MemberRow
|
||||
key={member.id}
|
||||
member={member}
|
||||
colorIndex={idx}
|
||||
onAction={(buttonRef) => openSheet(member, buttonRef)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── SHARED CALENDAR section ──────────────────────────────────────── */}
|
||||
<section aria-label="Shared Calendar">
|
||||
<div style={sectionLabelStyle}>Shared Calendar</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 var(--space-4, 16px) 0',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-secondary)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
The shared family calendar is visible to all members in the same color lane.
|
||||
</p>
|
||||
|
||||
{calendarsQuery.isLoading && (
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
padding: 'var(--space-2, 8px) 0',
|
||||
}}
|
||||
>
|
||||
Loading calendars…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{calendarsQuery.isError && (
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--color-destructive)',
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
padding: 'var(--space-2, 8px) 0',
|
||||
}}
|
||||
>
|
||||
Could not load calendars.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{calendarsQuery.data && calendarsQuery.data.calendars.length === 0 && (
|
||||
<EmptyCalendarsState />
|
||||
)}
|
||||
|
||||
{calendarsQuery.data && calendarsQuery.data.calendars.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Select shared calendar"
|
||||
style={{ marginBottom: 'var(--space-4, 16px)' }}
|
||||
>
|
||||
{calendarsQuery.data.calendars.map((cal) => (
|
||||
<CalendarRadioRow
|
||||
key={cal.id}
|
||||
calendar={cal}
|
||||
isSelected={effectiveSelected === cal.id}
|
||||
onSelect={() => setSelectedCalendarId(cal.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Two-tap Save button */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saveDisabled}
|
||||
onClick={() => {
|
||||
if (effectiveSelected !== null) {
|
||||
sharedCalMutation.mutate(effectiveSelected);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: saveDisabled
|
||||
? 'var(--color-border, #E2E4E9)'
|
||||
: 'var(--color-member-0, #4A90D9)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
cursor: saveDisabled ? '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',
|
||||
}}
|
||||
>
|
||||
{sharedCalMutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sharedCalMutation.isError && (
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--color-destructive)',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
marginTop: 'var(--space-2, 8px)',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
Something went wrong. Please try again.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Credential sheet — admin-rotate or admin-add */}
|
||||
{sheetMember && (
|
||||
<CredentialSheet
|
||||
isOpen={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
mode={sheetMode}
|
||||
memberName={sheetMember.displayName}
|
||||
memberId={sheetMember.id}
|
||||
triggerRef={triggerRef as React.RefObject<HTMLElement | null>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── MemberRow ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface MemberRowProps {
|
||||
member: AdminMember;
|
||||
colorIndex: number;
|
||||
onAction: (buttonRef: React.RefObject<HTMLButtonElement | null>) => void;
|
||||
}
|
||||
|
||||
function MemberRow({ member, colorIndex, onAction }: MemberRowProps) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-3, 12px)',
|
||||
minHeight: '44px',
|
||||
padding: 'var(--space-2, 8px) 0',
|
||||
borderBottom: '1px solid var(--color-border-subtle, var(--color-border))',
|
||||
}}
|
||||
>
|
||||
{/* Avatar swatch */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
background: `var(--color-member-${colorIndex}, var(--color-member-0))`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Name + status */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-primary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{member.displayName ?? 'Member'}
|
||||
</div>
|
||||
|
||||
{/* Credential status badge */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-1, 4px)',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{member.hasCredential ? (
|
||||
<>
|
||||
<CheckCircle
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
style={{ color: 'var(--color-text-secondary)', flexShrink: 0 }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Credential set
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle
|
||||
size={16}
|
||||
aria-hidden="true"
|
||||
style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
color: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
No credential
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── CalendarRadioRow ────────────────────────────────────────────────────────
|
||||
|
||||
interface CalendarRadioRowProps {
|
||||
calendar: AdminCalendar;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function CalendarRadioRow({ calendar, isSelected, onSelect }: CalendarRadioRowProps) {
|
||||
return (
|
||||
<div
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
tabIndex={0}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-3, 12px)',
|
||||
minHeight: '44px',
|
||||
padding: 'var(--space-2, 8px) 0',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{/* Radio indicator: 20px circle */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
flexShrink: 0,
|
||||
border: isSelected ? 'none' : '2px solid var(--color-border)',
|
||||
background: isSelected ? 'var(--color-member-0, #4A90D9)' : 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{isSelected && (
|
||||
<div
|
||||
style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: '#ffffff',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Calendar name */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-primary)',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{calendar.displayName}
|
||||
</span>
|
||||
|
||||
{/* Currently shared label */}
|
||||
{calendar.isShared && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-member-0, #4A90D9)',
|
||||
}}
|
||||
>
|
||||
Currently shared
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── EmptyCalendarsState ─────────────────────────────────────────────────────
|
||||
|
||||
function EmptyCalendarsState() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 'var(--space-8, 32px) 0',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
marginBottom: 'var(--space-2, 8px)',
|
||||
}}
|
||||
>
|
||||
No calendars synced yet
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Calendars sync automatically. Check back after the first sync completes.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user