Files
familysync/apps/pwa/src/components/PermissionDeniedBanner.tsx
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

101 lines
3.6 KiB
TypeScript

/**
* PermissionDeniedBanner — persistent OS-revoked notification banner (D-10).
*
* Shown ONLY when:
* - Notification.permission === 'denied' (OS revoked), AND
* - localStorage.notificationsEnabled was previously '1' (user had it on)
*
* Silent re-subscribe (D-10) covers the expired-subscription case. This banner
* is strictly for the OS-revoked case — the one case the app cannot silently fix.
*
* UI-SPEC §Surface 3:
* - role="alert" (assertive live region — permission loss is high-priority)
* - AlertCircle (--color-destructive) + "Notifications blocked" heading
* - "Re-enable in your browser settings." + "How to enable" inline button
* - No dismiss — persists until OS permission is restored
* - "How to enable" opens an OS-specific instruction sheet (iOS 4-step / Android 4-step)
*
* Security: T-05-24 — all copy is plain-text JSX children, no dangerouslySetInnerHTML.
*/
import { useState } from 'react';
import { AlertCircle } from 'lucide-react';
import { readNotificationsEnabled } from '../hooks/usePushSubscription.js';
import { InstructionSheet } from './InstructionSheet.js';
// ── PermissionDeniedBanner ────────────────────────────────────────────────
export function PermissionDeniedBanner() {
const [instructionsOpen, setInstructionsOpen] = useState(false);
// Only show when OS permission is 'denied' AND the user previously had notifications on.
// This is the OS-revoked case (D-10). Silent re-subscribe handles the expired-sub case.
const permissionDenied =
typeof Notification !== 'undefined' && Notification.permission === 'denied';
const wasEnabled = readNotificationsEnabled();
if (!permissionDenied || !wasEnabled) return null;
return (
<>
<div
role="alert"
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3, 12px)',
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
background: 'var(--color-surface-raised, #ffffff)',
borderBottom: '1px solid var(--color-border, #e2e4e9)',
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
}}
>
<AlertCircle
size={24}
aria-hidden="true"
style={{ color: 'var(--color-destructive, #DC2626)', flexShrink: 0 }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
color: 'var(--color-text-primary, #111318)',
lineHeight: 1.4,
}}
>
Notifications blocked
</div>
<div
style={{
fontSize: 'var(--text-label-size, 13px)',
color: 'var(--color-text-secondary, #5c6472)',
lineHeight: 1.4,
}}
>
Re-enable in your browser settings.{' '}
<button
onClick={() => setInstructionsOpen(true)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
fontSize: 'var(--text-label-size, 13px)',
color: 'var(--color-focus-ring, #4A90D9)',
textDecoration: 'underline',
fontFamily: 'inherit',
}}
>
How to enable
</button>
</div>
</div>
</div>
{instructionsOpen && <InstructionSheet onClose={() => setInstructionsOpen(false)} />}
</>
);
}