feat(10-04): add CredentialSheet and SetupBanner components
- CredentialSheet: admin-rotate/admin-add/self-service modes, role=dialog, aria-modal - Password field type=password autoComplete=new-password, never pre-filled (T-10-16) - Fastmail link target=_blank rel=noopener noreferrer (UI-SPEC Surface 3) - Loader2 spinner + CalDAV failure copy on mutation error - Success invalidates ['admin','members'] + ['me'] → SetupBanner unmounts - Escape closes, focus returns to trigger (a11y) - SetupBanner: renders on needsProviderSetup=true only, role=status aria-live=polite - KeyRound icon, 'Set up your calendar' heading, 'Set up now' CTA (no X/dismiss) - Success-only dismissal: ['me'] invalidation is the ONLY code path to hide the banner - All styling via var(--token); 44px touch targets throughout
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* SetupBanner — self-service credential onboarding banner (D-07).
|
||||
*
|
||||
* Shown ONLY when meQuery.data?.user.needsProviderSetup === true.
|
||||
* NO dismiss/X button — the ONLY way this clears is a successful credential save
|
||||
* which invalidates ['me'] → /api/me refetches → needsProviderSetup becomes false
|
||||
* → this component unmounts on the next render.
|
||||
*
|
||||
* Success-only dismissal contract:
|
||||
* CredentialSheet.onSuccess → invalidateQueries(['me']) → meQuery.data.user.needsProviderSetup=false
|
||||
* → SetupBanner returns null → banner disappears.
|
||||
* There is NO other code path that hides this banner.
|
||||
*
|
||||
* UI-SPEC §Surface 4:
|
||||
* - role="status" + aria-live="polite" (screen readers announce on load)
|
||||
* - KeyRound icon (size 20, var(--color-member-0))
|
||||
* - Heading "Set up your calendar" (15px/600/primary)
|
||||
* - Body copy (13px/400/secondary)
|
||||
* - "Set up now" CTA (accent-filled, minHeight 44px) → opens CredentialSheet in self-service mode
|
||||
* - Card style: var(--color-surface-dim), 1px border, var(--space-2) radius, var(--space-4) padding
|
||||
*
|
||||
* Security: T-05-24 — all copy is plain-text JSX children, no dangerouslySetInnerHTML.
|
||||
*/
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { KeyRound } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchMe } from '../api/client.js';
|
||||
import { CredentialSheet } from './CredentialSheet.js';
|
||||
|
||||
export function SetupBanner() {
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const ctaRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const meQuery = useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: fetchMe,
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Only show when needsProviderSetup is explicitly true
|
||||
// (undefined / false = no banner)
|
||||
if (meQuery.data?.user.needsProviderSetup !== true) return null;
|
||||
|
||||
const memberName = meQuery.data.user.displayName ?? 'Member';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
background: 'var(--color-surface-dim, #F7F7F8)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-2, 8px)',
|
||||
padding: 'var(--space-4, 16px)',
|
||||
margin: 'var(--space-4, 16px)',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 'var(--space-3, 12px)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
<KeyRound
|
||||
size={20}
|
||||
aria-hidden="true"
|
||||
style={{ color: 'var(--color-member-0, #4A90D9)', flexShrink: 0, marginTop: '2px' }}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
lineHeight: 1.4,
|
||||
marginBottom: 'var(--space-1, 4px)',
|
||||
}}
|
||||
>
|
||||
Set up your calendar
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-secondary)',
|
||||
lineHeight: 1.4,
|
||||
marginBottom: 'var(--space-3, 12px)',
|
||||
}}
|
||||
>
|
||||
To sync your Fastmail calendar, you need to add an app password. This takes about a
|
||||
minute.
|
||||
</div>
|
||||
|
||||
{/* CTA — no X/dismiss: the ONLY exit is a successful credential save */}
|
||||
<button
|
||||
ref={ctaRef}
|
||||
type="button"
|
||||
onClick={() => setSheetOpen(true)}
|
||||
style={{
|
||||
background: 'var(--color-member-0, #4A90D9)',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
cursor: '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)',
|
||||
}}
|
||||
>
|
||||
Set up now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CredentialSheet in self-service mode.
|
||||
On success: invalidates ['me'] → needsProviderSetup=false → this component unmounts. */}
|
||||
<CredentialSheet
|
||||
isOpen={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
mode="self-service"
|
||||
memberName={memberName}
|
||||
triggerRef={ctaRef as React.RefObject<HTMLElement | null>}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user