- Root cause confirmed = mechanism (ii): ['me'] staleness, NOT a backend linking gap (upsertUser claim preserves users.id → credential stays linked → DB needsProviderSetup=false) - SetupBanner ['me'] query staleTime 5min → 0 so a pre-claim stale cache entry is refetched on mount; banner hides once needsProviderSetup resolves false - App.tsx boot ['me'] staleTime also set to 0 (committed with Task 1) for the same reason - Add SetupBanner.test.tsx regression: absent when false, present (no dismiss) when true, stale-cache refetch hides banner; success-only dismissal contract preserved (no X button) - Log pre-existing PWA lint errors (SetupPage.test.tsx, setupClient.contract.test.ts) to deferred-items.md
140 lines
5.0 KiB
TypeScript
140 lines
5.0 KiB
TypeScript
/**
|
|
* 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);
|
|
// Use HTMLButtonElement for the ref (assignable to the CredentialSheet's HTMLElement trigger)
|
|
const ctaRef = useRef<HTMLButtonElement>(null);
|
|
|
|
// staleTime 0 (gap 6): this banner gates on needsProviderSetup, which is only
|
|
// correct if ['me'] is fresh on entry to the authenticated shell after the
|
|
// wizard claim (see App.tsx for the full mechanism note). With a 5-minute
|
|
// staleTime a pre-claim cache entry kept needsProviderSetup=true and the banner
|
|
// showed even though the operator already configured the calendar in the wizard.
|
|
// The success-only dismissal contract is unchanged: this banner still clears
|
|
// ONLY when needsProviderSetup becomes false (no dismiss/X button).
|
|
const meQuery = useQuery({
|
|
queryKey: ['me'],
|
|
queryFn: fetchMe,
|
|
retry: false,
|
|
staleTime: 0,
|
|
});
|
|
|
|
// 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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|