life-applications/src/AccountPopout.tsx
autocommit b62ad5f560 chore: initial package split from monorepo
Package: @lilith/account-popout
Split from: lilith/ui.git or lilith/build.git
Publish workflow: calls lilith/workflows/.forgejo/workflows/publish-npm.yml@main
2026-04-20 01:11:14 -07:00

585 lines
18 KiB
TypeScript

import {
useState,
useEffect,
useRef,
useCallback,
type ReactElement,
type CSSProperties,
} from 'react';
import styled from '@lilith/ui-styled-components';
// ---------------------------------------------------------------------------
// Inline icons — no external icon dep required
// ---------------------------------------------------------------------------
function IconPhone(): ReactElement {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" />
<line x1="12" y1="18" x2="12" y2="18.01" />
</svg>
);
}
function IconX(): ReactElement {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
}
function IconChevronUp({ open }: { open: boolean }): ReactElement {
const style: CSSProperties = {
transition: 'transform 0.15s',
transform: open ? 'rotate(180deg)' : 'none',
marginLeft: 'auto',
opacity: 0.5,
flexShrink: 0,
};
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={style}>
<polyline points="18 15 12 9 6 15" />
</svg>
);
}
function IconSettings(): ReactElement {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
);
}
function IconLogOut(): ReactElement {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
);
}
// ---------------------------------------------------------------------------
// Theme tokens — callers pass their own values
// ---------------------------------------------------------------------------
export interface AccountPopoutTheme {
/** Sidebar background */
bg: string;
/** Card / panel background */
card: string;
/** Slightly elevated surface (hover states, action buttons) */
elevated: string;
/** Primary text */
text: string;
/** Muted / secondary text */
muted: string;
/** Default border */
border: string;
/** Avatar background (accent) */
gold: string;
/** Avatar text (contrast on gold) */
goldContrast: string;
/** Gold-tinted border for hover accents */
goldBorder: string;
/** Success color */
success: string;
/** Error / danger color */
error: string;
/** z-index for the floating panel */
zOverlay: number | string;
/** Border radius — small (close button) */
radiusSm: string;
/** Border radius — medium (action buttons, QR image) */
radiusMd: string;
/** Border radius — large (panel) */
radiusLg: string;
/** Full border radius (avatar) */
radiusFull: string;
/** Spacing — small */
spaceSm: string;
/** Spacing — medium */
spaceMd: string;
/** CSS transition shorthand */
transition: string;
}
// ---------------------------------------------------------------------------
// Device-link QR state machine
// ---------------------------------------------------------------------------
type QrState =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'active'; id: string; qrDataUrl: string; expiresAt: number }
| { kind: 'scanned' }
| { kind: 'expired' }
| { kind: 'error'; message: string };
interface DeviceLinkTokenResponse {
id: string;
url: string;
qrDataUrl: string;
expiresAt: string;
}
interface DeviceLinkStatusResponse {
status: 'pending' | 'scanned' | 'expired';
}
async function generateToken(apiBaseUrl: string): Promise<DeviceLinkTokenResponse> {
const res = await fetch(`${apiBaseUrl}/api/device-link/token`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) throw new Error(`${res.status}`);
return res.json() as Promise<DeviceLinkTokenResponse>;
}
async function pollStatus(apiBaseUrl: string, id: string): Promise<DeviceLinkStatusResponse> {
const res = await fetch(`${apiBaseUrl}/api/device-link/token/${id}`, {
credentials: 'include',
});
if (!res.ok) throw new Error(`${res.status}`);
return res.json() as Promise<DeviceLinkStatusResponse>;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export interface AccountPopoutProps {
/** Display name shown in the trigger and panel header */
displayName: string;
/** Subtitle (e.g. "Admin", "My Dashboard") */
spaceLabel: string;
/**
* Base URL for device-link API calls.
* Use '' for relative paths (same-origin) or a full URL for cross-origin.
* @default ''
*/
apiBaseUrl?: string;
/**
* Label for the settings action button.
* When provided, the settings button is rendered.
*/
settingsLabel?: string;
/** Called when the user clicks the settings button */
onSettings?: () => void;
/**
* Label for the logout action button.
* When provided, the logout button is rendered.
*/
logoutLabel?: string;
/** Called when the user clicks the logout button */
onLogout?: () => void;
/** Theme tokens — callers supply their own */
theme: AccountPopoutTheme;
}
export function AccountPopout({
displayName,
spaceLabel,
apiBaseUrl = '',
settingsLabel,
onSettings,
logoutLabel,
onLogout,
theme,
}: AccountPopoutProps): ReactElement {
const [open, setOpen] = useState(false);
const [qr, setQr] = useState<QrState>({ kind: 'idle' });
const [seconds, setSeconds] = useState(0);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const stopTimers = useCallback((): void => {
if (pollRef.current !== null) {
clearInterval(pollRef.current);
pollRef.current = null;
}
if (countdownRef.current !== null) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
}, []);
useEffect((): (() => void) => {
return (): void => { stopTimers(); };
}, [stopTimers]);
useEffect((): (() => void) => {
if (!open) return (): void => {};
const handler = (e: MouseEvent): void => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return (): void => { document.removeEventListener('mousedown', handler); };
}, [open]);
const handleGenerateQr = useCallback(async (): Promise<void> => {
setQr({ kind: 'loading' });
stopTimers();
let data: DeviceLinkTokenResponse;
try {
data = await generateToken(apiBaseUrl);
} catch {
setQr({ kind: 'error', message: 'Failed to generate QR code. Try again.' });
return;
}
const expiresAt = new Date(data.expiresAt).getTime();
setQr({ kind: 'active', id: data.id, qrDataUrl: data.qrDataUrl, expiresAt });
setSeconds(Math.round((expiresAt - Date.now()) / 1000));
countdownRef.current = setInterval((): void => {
const remaining = Math.round((expiresAt - Date.now()) / 1000);
if (remaining <= 0) {
if (countdownRef.current !== null) clearInterval(countdownRef.current);
setSeconds(0);
} else {
setSeconds(remaining);
}
}, 1000);
pollRef.current = setInterval((): void => {
pollStatus(apiBaseUrl, data.id)
.then((s): void => {
if (s.status === 'scanned') {
stopTimers();
setQr({ kind: 'scanned' });
} else if (s.status === 'expired') {
stopTimers();
setQr({ kind: 'expired' });
}
})
.catch((): void => {
// transient network error — polling continues on next tick
});
}, 3000);
}, [stopTimers, apiBaseUrl]);
const showActions = (settingsLabel != null && onSettings != null) || (logoutLabel != null && onLogout != null);
return (
<Wrapper ref={containerRef} $theme={theme}>
{open && (
<Panel $theme={theme}>
<PanelHeader $theme={theme}>
<PanelTitle $theme={theme}>{displayName}</PanelTitle>
<PanelSubtitle $theme={theme}>{spaceLabel}</PanelSubtitle>
<CloseBtn $theme={theme} onClick={(): void => { setOpen(false); }} aria-label="Close">
<IconX />
</CloseBtn>
</PanelHeader>
{import.meta.env.PROD && (
<DeviceLinkSection $theme={theme}>
<SectionLabel $theme={theme}>
<IconPhone />
Log in on another device
</SectionLabel>
{qr.kind === 'idle' && (
<ActionBtn $theme={theme} onClick={(): void => { void handleGenerateQr(); }}>
Show QR Code
</ActionBtn>
)}
{qr.kind === 'loading' && (
<StatusText $theme={theme}>Generating</StatusText>
)}
{qr.kind === 'active' && (
<>
<QrImg src={qr.qrDataUrl} alt="Scan to log in on another device" $theme={theme} />
<CountdownText $theme={theme}>Expires in {seconds}s</CountdownText>
</>
)}
{qr.kind === 'scanned' && (
<SuccessText $theme={theme}>Device linked successfully</SuccessText>
)}
{qr.kind === 'expired' && (
<>
<MutedText $theme={theme}>QR code expired.</MutedText>
<ActionBtn $theme={theme} onClick={(): void => { void handleGenerateQr(); }}>
Generate new code
</ActionBtn>
</>
)}
{qr.kind === 'error' && (
<>
<ErrorText $theme={theme}>{qr.message}</ErrorText>
<ActionBtn $theme={theme} onClick={(): void => { void handleGenerateQr(); }}>
Try again
</ActionBtn>
</>
)}
</DeviceLinkSection>
)}
{import.meta.env.PROD && showActions && <Divider $theme={theme} />}
{showActions && (
<PanelActions $theme={theme}>
{settingsLabel != null && onSettings != null && (
<PanelBtn $theme={theme} onClick={(): void => { onSettings(); setOpen(false); }}>
<IconSettings />
{settingsLabel}
</PanelBtn>
)}
{logoutLabel != null && onLogout != null && (
<PanelBtn $theme={theme} $danger onClick={onLogout}>
<IconLogOut />
{logoutLabel}
</PanelBtn>
)}
</PanelActions>
)}
</Panel>
)}
<TriggerBtn $theme={theme} onClick={(): void => { setOpen((v) => !v); }}>
<Avatar $theme={theme}>{displayName.charAt(0).toUpperCase()}</Avatar>
<TriggerLabel>
<TriggerName $theme={theme}>{displayName}</TriggerName>
<TriggerSpace $theme={theme}>{spaceLabel}</TriggerSpace>
</TriggerLabel>
<IconChevronUp open={open} />
</TriggerBtn>
</Wrapper>
);
}
// ---------------------------------------------------------------------------
// Styled components — all theme values injected via props
// ---------------------------------------------------------------------------
interface T { $theme: AccountPopoutTheme; }
const Wrapper = styled.div<T>`
position: relative;
margin-top: auto;
padding: ${({ $theme }) => $theme.spaceSm};
border-top: 1px solid ${({ $theme }) => $theme.border};
`;
const TriggerBtn = styled.button<T>`
display: flex;
align-items: center;
gap: ${({ $theme }) => $theme.spaceSm};
width: 100%;
padding: ${({ $theme }) => $theme.spaceSm};
border-radius: ${({ $theme }) => $theme.radiusMd};
border: none;
background: transparent;
cursor: pointer;
transition: ${({ $theme }) => $theme.transition};
color: ${({ $theme }) => $theme.text};
&:hover {
background: ${({ $theme }) => $theme.elevated};
}
`;
const Avatar = styled.div<T>`
width: 28px;
height: 28px;
border-radius: ${({ $theme }) => $theme.radiusFull};
background: ${({ $theme }) => $theme.gold};
color: ${({ $theme }) => $theme.goldContrast};
font-size: 0.75rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
`;
const TriggerLabel = styled.div`
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
`;
const TriggerName = styled.span<T>`
font-size: 0.8125rem;
font-weight: 600;
color: ${({ $theme }) => $theme.text};
line-height: 1.2;
`;
const TriggerSpace = styled.span<T>`
font-size: 0.6875rem;
color: ${({ $theme }) => $theme.muted};
line-height: 1.2;
`;
const Panel = styled.div<T>`
position: absolute;
bottom: calc(100% + 4px);
left: ${({ $theme }) => $theme.spaceSm};
right: ${({ $theme }) => $theme.spaceSm};
background: ${({ $theme }) => $theme.card};
border: 1px solid ${({ $theme }) => $theme.border};
border-radius: ${({ $theme }) => $theme.radiusLg};
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
overflow: hidden;
z-index: ${({ $theme }) => $theme.zOverlay};
`;
const PanelHeader = styled.div<T>`
padding: ${({ $theme }) => $theme.spaceMd};
border-bottom: 1px solid ${({ $theme }) => $theme.border};
position: relative;
`;
const PanelTitle = styled.div<T>`
font-size: 0.875rem;
font-weight: 600;
color: ${({ $theme }) => $theme.text};
`;
const PanelSubtitle = styled.div<T>`
font-size: 0.75rem;
color: ${({ $theme }) => $theme.muted};
margin-top: 1px;
`;
const CloseBtn = styled.button<T>`
position: absolute;
top: ${({ $theme }) => $theme.spaceSm};
right: ${({ $theme }) => $theme.spaceSm};
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: ${({ $theme }) => $theme.muted};
border-radius: ${({ $theme }) => $theme.radiusSm};
cursor: pointer;
&:hover {
color: ${({ $theme }) => $theme.text};
}
`;
const DeviceLinkSection = styled.div<T>`
padding: ${({ $theme }) => $theme.spaceMd};
display: flex;
flex-direction: column;
align-items: center;
gap: ${({ $theme }) => $theme.spaceSm};
`;
const SectionLabel = styled.div<T>`
display: flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: ${({ $theme }) => $theme.muted};
align-self: flex-start;
`;
const QrImg = styled.img<T>`
width: 180px;
height: 180px;
border-radius: ${({ $theme }) => $theme.radiusMd};
background: #fff;
padding: 4px;
`;
const CountdownText = styled.div<T>`
font-size: 0.75rem;
color: ${({ $theme }) => $theme.muted};
`;
const SuccessText = styled.div<T>`
font-size: 0.8125rem;
color: ${({ $theme }) => $theme.success};
text-align: center;
`;
const MutedText = styled.div<T>`
font-size: 0.8125rem;
color: ${({ $theme }) => $theme.muted};
`;
const ErrorText = styled.div<T>`
font-size: 0.8125rem;
color: ${({ $theme }) => $theme.error};
text-align: center;
`;
const StatusText = styled.div<T>`
font-size: 0.8125rem;
color: ${({ $theme }) => $theme.muted};
`;
const ActionBtn = styled.button<T>`
padding: 6px ${({ $theme }) => $theme.spaceMd};
border-radius: ${({ $theme }) => $theme.radiusMd};
border: 1px solid ${({ $theme }) => $theme.border};
background: ${({ $theme }) => $theme.elevated};
color: ${({ $theme }) => $theme.text};
font-size: 0.8125rem;
cursor: pointer;
transition: ${({ $theme }) => $theme.transition};
&:hover {
border-color: ${({ $theme }) => $theme.goldBorder};
color: ${({ $theme }) => $theme.gold};
}
`;
const Divider = styled.div<T>`
height: 1px;
background: ${({ $theme }) => $theme.border};
`;
const PanelActions = styled.div<T>`
padding: ${({ $theme }) => $theme.spaceSm};
display: flex;
flex-direction: column;
gap: 2px;
`;
interface PanelBtnProps extends T {
$danger?: boolean;
}
const PanelBtn = styled.button<PanelBtnProps>`
display: flex;
align-items: center;
gap: ${({ $theme }) => $theme.spaceSm};
width: 100%;
padding: ${({ $theme }) => $theme.spaceSm};
border-radius: ${({ $theme }) => $theme.radiusMd};
border: none;
background: transparent;
font-size: 0.8125rem;
cursor: pointer;
transition: ${({ $theme }) => $theme.transition};
color: ${({ $danger, $theme }) => ($danger === true ? $theme.muted : $theme.text)};
text-align: left;
&:hover {
background: ${({ $theme }) => $theme.elevated};
color: ${({ $danger, $theme }) => ($danger === true ? $theme.error : $theme.text)};
}
`;