Package: @lilith/account-popout Split from: lilith/ui.git or lilith/build.git Publish workflow: calls lilith/workflows/.forgejo/workflows/publish-npm.yml@main
585 lines
18 KiB
TypeScript
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)};
|
|
}
|
|
`;
|