@columenlabs/lumen-ui 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -16,6 +16,8 @@ export { LumenGeoMap } from './lumen-geo-map.js';
16
16
  export { LumenWidget, type CardControls, type CardLink, LumenSavedWidget } from './lumen-widget.js';
17
17
  export { LumenConfirm, type LumenConfirmProps } from './confirm.js';
18
18
  export { type LumenAuthPoint, LumenAuthHeading, LumenAuthNote, LumenAuthProblem, LumenAuthStage, type LumenAuthStageProps, LumenCodeField, LumenEmailField, LumenPasswordField, LumenStepBar, } from './lumen-auth.js';
19
+ export { LumenBackupCodes, type LumenCodeMethod, type LumenPasskey, type LumenResult, LumenSecondStep, type LumenSecondStepProps, LumenSignInMethods, type LumenSignInMethodsProps, } from './lumen-security.js';
20
+ export { LumenDateInput, type LumenDateInputProps, stepDate, } from './lumen-date-input.js';
19
21
  export { LumenChoice, type LumenChoiceOption } from './lumen-choice.js';
20
22
  export { LumenSecret } from './lumen-secret.js';
21
23
  export { RankedBars, StackedBars, StackedColumns } from './lumen-widget-charts.js';
package/dist/index.js CHANGED
@@ -23,6 +23,8 @@ export { LumenGeoMap } from './lumen-geo-map.js';
23
23
  export { LumenWidget, LumenSavedWidget } from './lumen-widget.js';
24
24
  export { LumenConfirm } from './confirm.js';
25
25
  export { LumenAuthHeading, LumenAuthNote, LumenAuthProblem, LumenAuthStage, LumenCodeField, LumenEmailField, LumenPasswordField, LumenStepBar, } from './lumen-auth.js';
26
+ export { LumenBackupCodes, LumenSecondStep, LumenSignInMethods, } from './lumen-security.js';
27
+ export { LumenDateInput, stepDate, } from './lumen-date-input.js';
26
28
  export { LumenChoice } from './lumen-choice.js';
27
29
  export { LumenSecret } from './lumen-secret.js';
28
30
  export { RankedBars, StackedBars, StackedColumns } from './lumen-widget-charts.js';
@@ -15,7 +15,8 @@ export interface LumenAuthStageProps {
15
15
  body?: ReactNode;
16
16
  };
17
17
  points?: LumenAuthPoint[];
18
- /** A picture under the points, such as a diagram of what the app does. */
18
+ /** A picture under the points, such as a diagram of what the app does. Without one, the
19
+ * story is centred in the pane. */
19
20
  illustration?: ReactNode;
20
21
  /** Small links under the form (privacy, terms). */
21
22
  footer?: ReactNode;
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // The signed-out screens every app shares: a split page (the app's story on the left, hidden on
3
3
  // a phone, the form on the right) and the fields its forms are built from. The app passes its
4
4
  // brand, pitch, points, illustration and footer links; the kit draws the layout.
@@ -11,7 +11,8 @@ import { Input } from './ui/input.js';
11
11
  import { InputGroup, InputGroupAddon, InputGroupInput } from './ui/input-group.js';
12
12
  /** The page a signed-out screen sits in. */
13
13
  export function LumenAuthStage({ brand, pitch, points = [], illustration, footer, children, }) {
14
- return (_jsxs("div", { className: "grid min-h-svh grid-cols-1 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)]", children: [_jsxs("aside", { className: "hidden flex-col gap-10 border-r bg-sidebar px-10 py-8 text-sidebar-foreground lg:flex", children: [brand, _jsxs("div", { className: "flex flex-col gap-3", children: [_jsx("h2", { className: "text-balance font-semibold text-3xl leading-tight", children: pitch.title }), pitch.body && _jsx("p", { className: "max-w-md text-muted-foreground", children: pitch.body })] }), points.length > 0 && (_jsx("ul", { className: "flex flex-col gap-4", children: points.map(({ icon: Icon, title, body }) => (_jsxs("li", { className: "flex gap-3", children: [_jsx("span", { className: "flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground", children: _jsx(Icon, { className: "size-4.5" }) }), _jsxs("span", { className: "flex flex-col", children: [_jsx("span", { className: "font-medium text-sm", children: title }), _jsx("span", { className: "text-muted-foreground text-sm", children: body })] })] }, title))) })), illustration] }), _jsxs("main", { className: "flex flex-col bg-background px-6 py-8 sm:px-12", children: [_jsx("div", { className: "lg:hidden", children: brand }), _jsx("div", { className: "mx-auto flex w-full max-w-sm flex-1 flex-col justify-center py-8", children: children }), footer && _jsx("div", { className: "text-center text-muted-foreground text-xs", children: footer })] })] }));
14
+ const story = (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-3", children: [_jsx("h2", { className: "text-balance font-semibold text-3xl leading-tight", children: pitch.title }), pitch.body && _jsx("p", { className: "max-w-md text-muted-foreground", children: pitch.body })] }), points.length > 0 && (_jsx("ul", { className: "flex flex-col gap-4", children: points.map(({ icon: Icon, title, body }) => (_jsxs("li", { className: "flex gap-3", children: [_jsx("span", { className: "flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground", children: _jsx(Icon, { className: "size-4.5" }) }), _jsxs("span", { className: "flex flex-col", children: [_jsx("span", { className: "font-medium text-sm", children: title }), _jsx("span", { className: "text-muted-foreground text-sm", children: body })] })] }, title))) }))] }));
15
+ return (_jsxs("div", { className: "grid min-h-svh grid-cols-1 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)]", children: [_jsxs("aside", { className: "hidden flex-col gap-10 border-r bg-sidebar px-10 py-8 text-sidebar-foreground lg:flex", children: [brand, illustration ? (_jsxs(_Fragment, { children: [story, illustration] })) : (_jsx("div", { className: "flex flex-1 flex-col justify-center gap-10 pb-16", children: story }))] }), _jsxs("main", { className: "flex flex-col bg-background px-6 py-8 sm:px-12", children: [_jsx("div", { className: "lg:hidden", children: brand }), _jsx("div", { className: "mx-auto flex w-full max-w-sm flex-1 flex-col justify-center py-8", children: children }), footer && _jsx("div", { className: "text-center text-muted-foreground text-xs", children: footer })] })] }));
15
16
  }
16
17
  /** A screen's title, and a line under it. */
17
18
  export function LumenAuthHeading({ title, sub }) {
@@ -0,0 +1,30 @@
1
+ export type LumenDateInputProps = {
2
+ /** The day as ISO `yyyy-mm-dd`, or '' for none. */
3
+ value: string;
4
+ onChange: (iso: string) => void;
5
+ /** Earliest and latest day the picker and the steps reach. */
6
+ min?: string;
7
+ max?: string;
8
+ /** Which days the steps land on (a trading calendar); every day when left out. */
9
+ isOpen?: (iso: string) => boolean;
10
+ /** What a day is called in the step tooltips: "Previous trading day". Default "day". */
11
+ unit?: string;
12
+ /** Shows a Today icon button that runs this. */
13
+ onToday?: () => void;
14
+ /** Today is already shown: the Today button is off. */
15
+ atToday?: boolean;
16
+ /** `sm` for toolbars and card headers, `default` in forms. */
17
+ size?: 'sm' | 'default';
18
+ disabled?: boolean;
19
+ id?: string;
20
+ 'aria-label'?: string;
21
+ className?: string;
22
+ };
23
+ /** The next open day from `iso` in direction `dir`, inside min and max; null when none. */
24
+ export declare function stepDate(iso: string, dir: 1 | -1, { min, max, isOpen }?: Pick<LumenDateInputProps, 'min' | 'max' | 'isOpen'>): string | null;
25
+ /**
26
+ * The kit's date picker: the browser's date input between ‹ and › icon buttons that step
27
+ * one open day (an app passes its trading calendar as `isOpen`), Alt+← and Alt+→ while it
28
+ * has focus, and an optional Today. Every date field in an app uses it.
29
+ */
30
+ export declare function LumenDateInput({ value, onChange, min, max, isOpen, unit, onToday, atToday, size, disabled, id, 'aria-label': ariaLabel, className, }: LumenDateInputProps): import("react").JSX.Element;
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // A date picker with ‹ › to step one open day back or forward, and an optional Today.
3
+ import { CalendarClockIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
4
+ import { IconButton } from './icon-button.js';
5
+ import { cn } from './lib/utils.js';
6
+ import { Input } from './ui/input.js';
7
+ const DAY = 86_400_000;
8
+ /** The furthest a step looks for an open day (long holiday runs fit easily). */
9
+ const REACH = 14;
10
+ function addDays(iso, n) {
11
+ return new Date(Date.parse(`${iso}T00:00:00Z`) + n * DAY).toISOString().slice(0, 10);
12
+ }
13
+ /** The next open day from `iso` in direction `dir`, inside min and max; null when none. */
14
+ export function stepDate(iso, dir, { min, max, isOpen } = {}) {
15
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(iso))
16
+ return null;
17
+ let day = iso;
18
+ for (let i = 0; i < REACH; i++) {
19
+ day = addDays(day, dir);
20
+ if ((min && day < min) || (max && day > max))
21
+ return null;
22
+ if (!isOpen || isOpen(day))
23
+ return day;
24
+ }
25
+ return null;
26
+ }
27
+ /**
28
+ * The kit's date picker: the browser's date input between ‹ and › icon buttons that step
29
+ * one open day (an app passes its trading calendar as `isOpen`), Alt+← and Alt+→ while it
30
+ * has focus, and an optional Today. Every date field in an app uses it.
31
+ */
32
+ export function LumenDateInput({ value, onChange, min, max, isOpen, unit = 'day', onToday, atToday, size = 'sm', disabled, id, 'aria-label': ariaLabel, className, }) {
33
+ const prev = disabled ? null : stepDate(value, -1, { min, max, isOpen });
34
+ const next = disabled ? null : stepDate(value, 1, { min, max, isOpen });
35
+ const btn = size === 'sm' ? 'icon-xs' : 'icon-sm';
36
+ const onKeyDown = (e) => {
37
+ if (!e.altKey)
38
+ return;
39
+ const to = e.key === 'ArrowLeft' ? prev : e.key === 'ArrowRight' ? next : undefined;
40
+ if (to === undefined)
41
+ return;
42
+ e.preventDefault();
43
+ if (to)
44
+ onChange(to);
45
+ };
46
+ return (_jsxs("div", { className: cn('inline-flex shrink-0 items-center gap-0.5', className), children: [_jsx(IconButton, { icon: ChevronLeftIcon, label: `Previous ${unit}`, size: btn, disabled: !prev, onClick: () => prev && onChange(prev) }), _jsx(Input, { type: "date", id: id, "aria-label": ariaLabel ?? 'Date', className: cn('tabular-nums', size === 'sm' ? 'h-7 w-32 text-xs md:text-xs' : 'w-36'), min: min, max: max, value: value, disabled: disabled, onKeyDown: onKeyDown, onChange: (e) => onChange(e.target.value) }), _jsx(IconButton, { icon: ChevronRightIcon, label: `Next ${unit}`, size: btn, disabled: !next, onClick: () => next && onChange(next) }), onToday && (_jsx(IconButton, { icon: CalendarClockIcon, label: "Today", size: btn, disabled: disabled || atToday, onClick: onToday }))] }));
47
+ }
@@ -0,0 +1,92 @@
1
+ /** What a callback answers: a problem to show the person (a string), or anything else when it worked. */
2
+ export type LumenResult = Promise<unknown>;
3
+ /** The ways to pass the second step that take a typed code. */
4
+ export type LumenCodeMethod = 'email' | 'app' | 'backup';
5
+ export interface LumenSecondStepProps {
6
+ /** The ways this person can pass the second step. */
7
+ methods: {
8
+ passkey?: boolean;
9
+ app?: boolean;
10
+ email?: boolean;
11
+ backup?: boolean;
12
+ };
13
+ /**
14
+ * The code method shown first (the app sends the first emailed code itself). Left out, it is
15
+ * the app, then email, then backup, whichever the person has; with none, only the passkey shows.
16
+ */
17
+ initial?: LumenCodeMethod;
18
+ /** Where emailed codes go, as the app wants it shown (masked or whole). */
19
+ emailTo?: string;
20
+ onPasskey?: () => LumenResult;
21
+ /** Sends a new emailed code. */
22
+ onSendEmail?: () => LumenResult;
23
+ onVerify: (method: LumenCodeMethod, code: string) => LumenResult;
24
+ /** Back to the first step; no link without it. */
25
+ onBack?: () => void;
26
+ }
27
+ /**
28
+ * The second step of a sign-in: the passkey first when the person has one, else a 6-digit code
29
+ * from their authenticator app or email, or a backup code. Goes inside LumenAuthStage.
30
+ */
31
+ export declare function LumenSecondStep({ methods, initial, emailTo, onPasskey, onSendEmail, onVerify, onBack, }: LumenSecondStepProps): import("react").JSX.Element;
32
+ /** Backup codes, shown once: copy, download, and a box to tick before Done. */
33
+ export declare function LumenBackupCodes({ codes, onDone, fileName, doneLabel, }: {
34
+ codes: string[];
35
+ onDone: () => void;
36
+ fileName?: string;
37
+ doneLabel?: string;
38
+ }): import("react").JSX.Element;
39
+ /** A passkey the person has added. */
40
+ export interface LumenPasskey {
41
+ id: string;
42
+ name?: string | null;
43
+ createdAt?: Date | string | null;
44
+ }
45
+ export interface LumenSignInMethodsProps {
46
+ /** The password row; its Change opens the app's own flow. */
47
+ password?: {
48
+ onChange: () => void;
49
+ };
50
+ passkeys?: {
51
+ list: LumenPasskey[];
52
+ /** Adds one under the name the person typed; the device asks for their face, finger or PIN. */
53
+ onAdd: (name: string) => LumenResult;
54
+ onRemove: (id: string) => LumenResult;
55
+ };
56
+ /** The authenticator app (TOTP). Turning it on asks for the password first. */
57
+ app?: {
58
+ on: boolean;
59
+ /** Checks the password and starts setup: the app's otpauth:// link and fresh backup codes. */
60
+ onStart: (password: string) => Promise<{
61
+ uri: string;
62
+ backupCodes: string[];
63
+ } | string>;
64
+ /** Confirms setup with the first code the app shows. */
65
+ onConfirm: (code: string) => LumenResult;
66
+ /** Turns it off after the password; no Turn off without it. */
67
+ onTurnOff?: (password: string) => LumenResult;
68
+ /** Whether Replace shows while it is on (set up again over the old one); true when absent. */
69
+ replaceable?: boolean;
70
+ };
71
+ /** A code emailed at each sign-in. */
72
+ email?: {
73
+ /** Where the codes go, as the app wants it shown. */
74
+ to: string;
75
+ on: boolean;
76
+ /** No switch without it: emailed codes stay on. */
77
+ onToggle?: (on: boolean) => LumenResult;
78
+ /** Why the switch is locked (it is the only second step left); unlocked when absent. */
79
+ locked?: string;
80
+ };
81
+ backup?: {
82
+ /** Codes left, or null when unknown. */
83
+ left: number | null;
84
+ /** Checks the password and makes 10 new codes; the old ones stop working. */
85
+ onRenew: (password: string) => Promise<string[] | string>;
86
+ };
87
+ }
88
+ /**
89
+ * Settings ▸ General ▸ Security: the password, then the second-step methods (passkeys, an
90
+ * authenticator app, emailed codes, backup codes). Adding or changing one opens its steps in place.
91
+ */
92
+ export declare function LumenSignInMethods({ password, passkeys, app, email, backup, }: LumenSignInMethodsProps): import("react").JSX.Element;
@@ -0,0 +1,200 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Two-step sign-in, the same on every app: the second step after a password (LumenSecondStep), the
3
+ // sign-in methods in Settings ▸ General ▸ Security (LumenSignInMethods), and the backup codes shown
4
+ // once (LumenBackupCodes). They draw the screens and call the app back; the app talks to its own
5
+ // sign-in service and decides which methods it allows. A callback answers with a problem to show,
6
+ // or nothing when it worked.
7
+ import { Check, Copy, Download, Fingerprint, Lock, Mail, Plus, Smartphone, Ticket, } from 'lucide-react';
8
+ import { QRCodeSVG } from 'qrcode.react';
9
+ import { useState } from 'react';
10
+ import { LumenAuthHeading, LumenAuthNote, LumenAuthProblem, LumenCodeField, LumenPasswordField, LumenStepBar, } from './lumen-auth.js';
11
+ import { SettingRow } from './settings.js';
12
+ import { Badge } from './ui/badge.js';
13
+ import { Button } from './ui/button.js';
14
+ import { Checkbox } from './ui/checkbox.js';
15
+ import { FieldSeparator } from './ui/field.js';
16
+ import { Input } from './ui/input.js';
17
+ import { Label } from './ui/label.js';
18
+ import { Switch } from './ui/switch.js';
19
+ /** The problem a callback answered, if any; a throw counts as one. */
20
+ const problemOf = (work) => work().then((r) => (typeof r === 'string' && r ? r : null), () => 'That didn’t work. Try again.');
21
+ const link = 'text-primary underline-offset-4 hover:underline';
22
+ /**
23
+ * The second step of a sign-in: the passkey first when the person has one, else a 6-digit code
24
+ * from their authenticator app or email, or a backup code. Goes inside LumenAuthStage.
25
+ */
26
+ export function LumenSecondStep({ methods, initial, emailTo, onPasskey, onSendEmail, onVerify, onBack, }) {
27
+ const [method, setMethod] = useState(initial ?? ['app', 'email', 'backup'].find((m) => methods[m]) ?? null);
28
+ const [code, setCode] = useState('');
29
+ const [error, setError] = useState(null);
30
+ const [note, setNote] = useState(null);
31
+ const [busy, setBusy] = useState(false);
32
+ const run = async (work) => {
33
+ setError(null);
34
+ setBusy(true);
35
+ const problem = await problemOf(work);
36
+ setBusy(false);
37
+ if (problem)
38
+ setError(problem);
39
+ };
40
+ const switchTo = async (m) => {
41
+ setMethod(m);
42
+ setCode('');
43
+ setError(null);
44
+ setNote(null);
45
+ if (m === 'email' && onSendEmail) {
46
+ await run(onSendEmail);
47
+ setNote(emailTo ? `We emailed a code to ${emailTo}.` : 'We emailed you a code.');
48
+ }
49
+ };
50
+ const verify = (e) => {
51
+ e.preventDefault();
52
+ if (!method)
53
+ return;
54
+ if (method !== 'backup' && code.length !== 6)
55
+ return setError('Enter all 6 digits.');
56
+ return run(() => onVerify(method, code.trim()));
57
+ };
58
+ const others = [
59
+ { m: 'app', label: 'Use my authenticator app instead' },
60
+ { m: 'email', label: 'Email me a code instead' },
61
+ { m: 'backup', label: 'Use a backup code' },
62
+ ].filter((o) => o.m !== method && methods[o.m]);
63
+ const sub = method === 'email'
64
+ ? emailTo
65
+ ? `We emailed a code to ${emailTo}.`
66
+ : 'We emailed you a code.'
67
+ : method === 'app'
68
+ ? 'Enter the code from your authenticator app.'
69
+ : method === 'backup'
70
+ ? 'Enter one of the backup codes you saved. Each works once.'
71
+ : 'Use your passkey to finish signing in.';
72
+ return (_jsxs(_Fragment, { children: [_jsx(LumenAuthHeading, { title: "Confirm it\u2019s you", sub: sub }), _jsxs("div", { className: "flex flex-col gap-4", children: [methods.passkey && onPasskey && (_jsxs(_Fragment, { children: [_jsxs(Button, { size: "lg", className: "h-10", disabled: busy, onClick: () => run(onPasskey), children: [_jsx(Fingerprint, {}), " ", busy ? 'Waiting for your passkey…' : 'Use my passkey'] }), method && _jsx(FieldSeparator, { children: "or enter the code" })] })), _jsx(LumenAuthNote, { children: note }), method && (_jsxs("form", { className: "flex flex-col gap-4", onSubmit: verify, children: [method === 'backup' ? (_jsxs(_Fragment, { children: [_jsx(Input, { "aria-label": "Backup code", autoComplete: "one-time-code", className: "h-12 text-center font-code text-lg", value: code, onChange: (e) => setCode(e.target.value) }), _jsx(LumenAuthProblem, { children: error })] })) : (_jsx(LumenCodeField, { value: code, onChange: setCode, error: error, hint: method === 'email'
73
+ ? 'It expires in 5 minutes.'
74
+ : 'The code changes every 30 seconds.', onResend: method === 'email' && onSendEmail
75
+ ? async () => {
76
+ await run(onSendEmail);
77
+ setNote(emailTo ? `New code sent to ${emailTo}.` : 'New code sent.');
78
+ }
79
+ : undefined })), _jsx(Button, { size: "lg", variant: "outline", type: "submit", className: "h-10", disabled: busy, children: "Verify" })] })), _jsxs("div", { className: "flex flex-col items-center gap-2 text-sm", children: [others.map((o) => (_jsx("button", { type: "button", className: link, onClick: () => switchTo(o.m), children: o.label }, o.m))), onBack && (_jsx("button", { type: "button", className: link, onClick: onBack, children: "Back to sign in" }))] })] })] }));
80
+ }
81
+ /** Backup codes, shown once: copy, download, and a box to tick before Done. */
82
+ export function LumenBackupCodes({ codes, onDone, fileName = 'backup-codes.txt', doneLabel = 'Done', }) {
83
+ const [saved, setSaved] = useState(false);
84
+ const text = codes.join('\n');
85
+ const download = () => {
86
+ const url = URL.createObjectURL(new Blob([`${text}\n`], { type: 'text/plain' }));
87
+ const a = document.createElement('a');
88
+ a.href = url;
89
+ a.download = fileName;
90
+ a.click();
91
+ URL.revokeObjectURL(url);
92
+ };
93
+ return (_jsxs("div", { className: "flex flex-col gap-4", children: [_jsx("p", { className: "text-muted-foreground text-sm", children: "If you lose your devices, each code signs you in once. They are shown only now." }), _jsx("div", { className: "grid grid-cols-2 gap-x-3 gap-y-1 rounded-lg bg-muted p-3 font-code text-sm", children: codes.map((c) => (_jsx("span", { className: "text-center", children: c }, c))) }), _jsxs("div", { className: "flex gap-2", children: [_jsxs(Button, { variant: "outline", className: "flex-1", onClick: () => navigator.clipboard?.writeText(text), children: [_jsx(Copy, {}), " Copy"] }), _jsxs(Button, { variant: "outline", className: "flex-1", onClick: download, children: [_jsx(Download, {}), " Download"] })] }), _jsxs(Label, { className: "flex items-center gap-2", children: [_jsx(Checkbox, { checked: saved, onCheckedChange: (v) => setSaved(v === true) }), " I saved them somewhere safe"] }), _jsx(Button, { disabled: !saved, onClick: onDone, children: doneLabel })] }));
94
+ }
95
+ const when = (d) => d
96
+ ? new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
97
+ : '';
98
+ /** A row's icon, in the same square as the sign-in screen's points. */
99
+ function RowIcon({ children }) {
100
+ return (_jsx("span", { className: "flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground [&_svg]:size-4", children: children }));
101
+ }
102
+ /**
103
+ * Settings ▸ General ▸ Security: the password, then the second-step methods (passkeys, an
104
+ * authenticator app, emailed codes, backup codes). Adding or changing one opens its steps in place.
105
+ */
106
+ export function LumenSignInMethods({ password, passkeys, app, email, backup, }) {
107
+ const [flow, setFlow] = useState(null);
108
+ const [name, setName] = useState('');
109
+ const [secret, setSecret] = useState('');
110
+ const [code, setCode] = useState('');
111
+ const [setup, setSetup] = useState(null);
112
+ const [codes, setCodes] = useState([]);
113
+ const [removing, setRemoving] = useState(null);
114
+ const [error, setError] = useState(null);
115
+ const [busy, setBusy] = useState(false);
116
+ const open = (f) => {
117
+ setFlow(f);
118
+ setError(null);
119
+ setSecret('');
120
+ setCode('');
121
+ setName('');
122
+ };
123
+ const run = async (work, next) => {
124
+ setError(null);
125
+ setBusy(true);
126
+ const problem = await problemOf(work);
127
+ setBusy(false);
128
+ if (problem)
129
+ return setError(problem);
130
+ next?.();
131
+ };
132
+ const cancel = (_jsx(Button, { type: "button", variant: "ghost", onClick: () => open(null), disabled: busy, children: "Cancel" }));
133
+ if (flow === 'passkey' && passkeys) {
134
+ return (_jsxs("form", { className: "flex max-w-md flex-col gap-4 py-2", onSubmit: (e) => {
135
+ e.preventDefault();
136
+ run(() => passkeys.onAdd(name.trim()), () => open(null));
137
+ }, children: [_jsx("h4", { className: "font-semibold", children: "Add a passkey" }), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: "lumen-passkey-name", children: "Name it" }), _jsx(Input, { id: "lumen-passkey-name", placeholder: "MacBook, iPhone, YubiKey", value: name, onChange: (e) => setName(e.target.value) }), _jsx("p", { className: "text-muted-foreground text-xs", children: "So you can tell your passkeys apart later." })] }), _jsx("p", { className: "text-muted-foreground text-sm", children: "Your device asks for your face, fingerprint or PIN. That never leaves the device." }), _jsx(LumenAuthProblem, { children: error }), _jsxs("div", { className: "flex gap-2", children: [_jsxs(Button, { type: "submit", disabled: busy, children: [_jsx(Fingerprint, {}), " ", busy ? 'Waiting for your device…' : 'Add passkey'] }), cancel] })] }));
138
+ }
139
+ // The steps that ask for the password first: turning the app on or off, and new backup codes.
140
+ if ((flow === 'app' && app) ||
141
+ (flow === 'app-off' && app?.onTurnOff) ||
142
+ (flow === 'backup' && backup)) {
143
+ const title = flow === 'app'
144
+ ? 'Set up an authenticator app'
145
+ : flow === 'app-off'
146
+ ? 'Turn off the authenticator app'
147
+ : 'Make new backup codes';
148
+ const submit = (e) => {
149
+ e.preventDefault();
150
+ if (flow === 'app' && app) {
151
+ return run(async () => {
152
+ const res = await app.onStart(secret);
153
+ if (typeof res === 'string')
154
+ return res;
155
+ setSetup(res);
156
+ open('app-confirm');
157
+ });
158
+ }
159
+ if (flow === 'app-off' && app?.onTurnOff)
160
+ return run(() => app.onTurnOff?.(secret) ?? Promise.resolve(), () => open(null));
161
+ if (flow === 'backup' && backup) {
162
+ return run(async () => {
163
+ const res = await backup.onRenew(secret);
164
+ if (typeof res === 'string')
165
+ return res;
166
+ setCodes(res);
167
+ open('codes');
168
+ });
169
+ }
170
+ };
171
+ return (_jsxs("form", { className: "flex max-w-md flex-col gap-4 py-2", onSubmit: submit, children: [flow === 'app' && _jsx(LumenStepBar, { at: 1, of: 3 }), _jsx("h4", { className: "font-semibold", children: title }), _jsx("p", { className: "text-muted-foreground text-sm", children: "First, confirm it\u2019s you with your password." }), _jsx(LumenPasswordField, { value: secret, onChange: setSecret, autoComplete: "current-password" }), _jsx(LumenAuthProblem, { children: error }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "submit", variant: flow === 'app-off' ? 'destructive' : 'default', disabled: busy || !secret, children: flow === 'app-off' ? 'Turn off' : 'Continue' }), cancel] })] }));
172
+ }
173
+ if (flow === 'app-confirm' && app && setup) {
174
+ const key = new URL(setup.uri).searchParams.get('secret') ?? '';
175
+ return (_jsxs("form", { className: "flex max-w-md flex-col gap-4 py-2", onSubmit: (e) => {
176
+ e.preventDefault();
177
+ if (code.length !== 6)
178
+ return setError('Enter all 6 digits.');
179
+ return run(() => app.onConfirm(code), () => {
180
+ setCodes(setup.backupCodes);
181
+ open('codes');
182
+ });
183
+ }, children: [_jsx(LumenStepBar, { at: 2, of: 3 }), _jsx("h4", { className: "font-semibold", children: "Scan this code" }), _jsx("p", { className: "text-muted-foreground text-sm", children: "Open 1Password, Google Authenticator or any authenticator app, then scan it." }), _jsx(QRCodeSVG, { value: setup.uri, size: 160, marginSize: 2, className: "self-center rounded-md bg-white" }), _jsxs("div", { className: "flex flex-col items-center gap-1 text-sm", children: [_jsx("span", { className: "text-muted-foreground", children: "Can\u2019t scan it? Enter this key:" }), _jsxs("button", { type: "button", className: "flex items-center gap-2 break-all font-code", onClick: () => navigator.clipboard?.writeText(key), children: [key.match(/.{1,4}/g)?.join(' '), " ", _jsx(Copy, { className: "size-3.5 shrink-0" })] })] }), _jsx(LumenCodeField, { value: code, onChange: setCode, error: error, hint: "Enter the 6 digits it shows." }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "submit", disabled: busy, children: "Turn on" }), cancel] })] }));
184
+ }
185
+ if (flow === 'codes') {
186
+ return (_jsxs("div", { className: "flex max-w-md flex-col gap-2 py-2", children: [setup && _jsx(LumenStepBar, { at: 3, of: 3 }), _jsx("h4", { className: "font-semibold", children: "Save your backup codes" }), _jsx(LumenBackupCodes, { codes: codes, onDone: () => {
187
+ setSetup(null);
188
+ setCodes([]);
189
+ open(null);
190
+ } })] }));
191
+ }
192
+ const list = passkeys?.list ?? [];
193
+ return (_jsxs("div", { className: "flex flex-col", children: [_jsx(LumenAuthProblem, { children: error }), password && (_jsx(SettingRow, { label: "Password", description: "The first step, every time.", children: _jsxs(Button, { size: "sm", variant: "outline", onClick: password.onChange, children: [_jsx(Lock, {}), " Change"] }) })), _jsx("p", { className: "pt-4 pb-1 font-semibold text-muted-foreground text-xs uppercase tracking-wide", children: "Second step \u00B7 one of these, every time" }), passkeys && (_jsxs(_Fragment, { children: [_jsx(SettingRow, { label: "Passkeys", description: list.length
194
+ ? `${list.length} added. The fastest second step, and it can’t be phished.`
195
+ : 'None yet. The fastest second step, and it can’t be phished.', children: _jsxs(Button, { size: "sm", onClick: () => open('passkey'), children: [_jsx(Plus, {}), " Add a passkey"] }) }), list.map((p) => (_jsxs("div", { className: "flex items-center gap-3 border-b py-2 pl-4 text-sm", children: [_jsx(Fingerprint, { className: "size-4 shrink-0 text-muted-foreground" }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsx("span", { className: "truncate font-medium", children: p.name || 'Passkey' }), p.createdAt && (_jsxs("span", { className: "text-muted-foreground text-xs", children: ["Added ", when(p.createdAt)] }))] }), removing === p.id ? (_jsxs(_Fragment, { children: [_jsx(Button, { size: "sm", variant: "destructive", disabled: busy, onClick: () => run(() => passkeys.onRemove(p.id), () => setRemoving(null)), children: "Remove" }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => setRemoving(null), children: "Keep" })] })) : (_jsx(Button, { size: "sm", variant: "ghost", className: "text-destructive", onClick: () => setRemoving(p.id), children: "Remove" }))] }, p.id)))] })), app && (_jsx(SettingRow, { label: "Authenticator app", description: app.on
196
+ ? 'On. A 6-digit code from your app.'
197
+ : 'A 6-digit code from 1Password, Google Authenticator or similar.', children: _jsxs("div", { className: "flex items-center gap-2", children: [app.on && (_jsxs(Badge, { variant: "secondary", children: [_jsx(Check, {}), " On"] })), (!app.on || app.replaceable !== false) && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => open('app'), children: [_jsx(Smartphone, {}), " ", app.on ? 'Replace' : 'Set up'] })), app.on && app.onTurnOff && (_jsx(Button, { size: "sm", variant: "ghost", className: "text-destructive", onClick: () => open('app-off'), children: "Turn off" }))] }) })), email && (_jsx(SettingRow, { label: "Emailed code", description: email.locked && email.on ? email.locked : `A 6-digit code to ${email.to}.`, htmlFor: "lumen-email-code", children: email.onToggle ? (_jsx(Switch, { id: "lumen-email-code", checked: email.on, disabled: busy || (email.on && Boolean(email.locked)), onCheckedChange: (v) => run(() => email.onToggle?.(v) ?? Promise.resolve()) })) : (_jsx(Mail, { className: "size-4 text-muted-foreground" })) })), backup && (_jsx(SettingRow, { label: "Backup codes", description: backup.left === null
198
+ ? 'Each works once, for when your devices are lost.'
199
+ : `${backup.left} left. Each works once, for when your devices are lost.`, children: _jsxs(Button, { size: "sm", variant: "outline", onClick: () => open('backup'), children: [_jsx(Ticket, {}), " Make new codes"] }) }))] }));
200
+ }
@@ -33,8 +33,8 @@ export declare function ThemeToggle({ compact }: {
33
33
  compact?: boolean;
34
34
  }): import("react").JSX.Element;
35
35
  /** Every Settings section in the order the dialog lists them: General, the product's, the app's. */
36
- export declare function allSettings(profile: ReactNode | undefined, shared?: SettingsSectionDef[], sections?: SettingsSectionDef[]): SettingsSectionDef[];
37
- export declare function LumenSettings({ open, section, onOpen, onClose, appLabel, profile, sections, shared, sharedLabel, }: {
36
+ export declare function allSettings(profile: ReactNode | undefined, shared?: SettingsSectionDef[], sections?: SettingsSectionDef[], security?: ReactNode): SettingsSectionDef[];
37
+ export declare function LumenSettings({ open, section, onOpen, onClose, appLabel, profile, security, sections, shared, sharedLabel, }: {
38
38
  open: boolean;
39
39
  /** The section shown; unknown ids show Profile. */
40
40
  section: string;
@@ -44,6 +44,8 @@ export declare function LumenSettings({ open, section, onOpen, onClose, appLabel
44
44
  appLabel: string;
45
45
  /** Profile's content, the first General section; none without it. */
46
46
  profile?: ReactNode;
47
+ /** Security's content, after Profile (LumenSignInMethods); none without it. */
48
+ security?: ReactNode;
47
49
  sections?: SettingsSectionDef[];
48
50
  /** Sections the product adds to every app (the assistant), listed under `sharedLabel` after General. */
49
51
  shared?: SettingsSectionDef[];
package/dist/settings.js CHANGED
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  // sections (an app: Repos). On phone the list becomes a picker.
7
7
  // A search box tops the list and filters the rows on every section.
8
8
  // `?settings=<section>` in the URL opens it at that section (GitHub's install page returns there).
9
- import { LogOutIcon, MonitorIcon, MoonIcon, PaletteIcon, SearchIcon, SlidersHorizontalIcon, SunIcon, UserRoundIcon, XIcon, } from 'lucide-react';
9
+ import { LogOutIcon, MonitorIcon, MoonIcon, PaletteIcon, SearchIcon, ShieldCheckIcon, SlidersHorizontalIcon, SunIcon, UserRoundIcon, XIcon, } from 'lucide-react';
10
10
  import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, } from 'react';
11
11
  import { createPortal } from 'react-dom';
12
12
  import { toast } from 'sonner';
@@ -79,6 +79,12 @@ const PROFILE = {
79
79
  icon: UserRoundIcon,
80
80
  description: 'Your name and sign-in on every desk.',
81
81
  };
82
+ const SECURITY = {
83
+ id: 'security',
84
+ title: 'Security',
85
+ icon: ShieldCheckIcon,
86
+ description: 'How you sign in. Changes ask you to confirm it’s you.',
87
+ };
82
88
  const GENERAL = [
83
89
  {
84
90
  id: 'appearance',
@@ -96,12 +102,16 @@ const GENERAL = [
96
102
  },
97
103
  ];
98
104
  /** Every Settings section in the order the dialog lists them: General, the product's, the app's. */
99
- export function allSettings(profile, shared = [], sections = []) {
100
- const general = profile ? [{ ...PROFILE, content: profile }, ...GENERAL] : GENERAL;
105
+ export function allSettings(profile, shared = [], sections = [], security) {
106
+ const general = [
107
+ ...(profile ? [{ ...PROFILE, content: profile }] : []),
108
+ ...(security ? [{ ...SECURITY, content: security }] : []),
109
+ ...GENERAL,
110
+ ];
101
111
  return [...general, ...shared, ...sections];
102
112
  }
103
- export function LumenSettings({ open, section, onOpen, onClose, appLabel, profile, sections = [], shared = [], sharedLabel = 'All apps', }) {
104
- const all = allSettings(profile, shared, sections);
113
+ export function LumenSettings({ open, section, onOpen, onClose, appLabel, profile, security, sections = [], shared = [], sharedLabel = 'All apps', }) {
114
+ const all = allSettings(profile, shared, sections, security);
105
115
  const general = all.slice(0, all.length - shared.length - sections.length);
106
116
  const current = all.find((s) => s.id === section) ?? all[0];
107
117
  // The assistant opens Settings at a section (it never changes a setting); Undo puts it back as it was.
package/dist/shell.d.ts CHANGED
@@ -69,6 +69,8 @@ export interface LumenShellProps {
69
69
  signOutUrl?: string;
70
70
  /** Settings ▸ General ▸ Profile's content; no Profile section without it. */
71
71
  profile?: ReactNode;
72
+ /** Settings ▸ General ▸ Security's content (LumenSignInMethods); no Security section without it. */
73
+ security?: ReactNode;
72
74
  /** Reloads the page's data and drops every cached read (TanStack Query `invalidateQueries`);
73
75
  * shows Refresh in the header, which then mounts the page again. */
74
76
  onRefresh?: () => Promise<unknown>;
@@ -87,7 +89,7 @@ export interface LumenShellProps {
87
89
  /** The routed page (usually `<Outlet />`). */
88
90
  children: ReactNode;
89
91
  }
90
- export declare function LumenShell({ app, appLabel, brand, nav, status, health, statusEnd, releases, settings, shared, sharedLabel, user, signOutUrl, profile, onRefresh, hideAmounts, onLogs, headerActions, panel, finder, wrap, children, }: LumenShellProps): import("react").JSX.Element;
92
+ export declare function LumenShell({ app, appLabel, brand, nav, status, health, statusEnd, releases, settings, shared, sharedLabel, user, signOutUrl, profile, security, onRefresh, hideAmounts, onLogs, headerActions, panel, finder, wrap, children, }: LumenShellProps): import("react").JSX.Element;
91
93
  /** A status bar entry: an optional health dot, a label and a mono value. */
92
94
  export declare function StatusItem({ label, value, ok, className, }: {
93
95
  label: ReactNode;
package/dist/shell.js CHANGED
@@ -88,7 +88,7 @@ const settingsFromUrl = (() => {
88
88
  window.history.replaceState(window.history.state, '', url);
89
89
  return at || 'profile';
90
90
  })();
91
- export function LumenShell({ app, appLabel, brand, nav, status, health, statusEnd, releases, settings, shared, sharedLabel, user = null, signOutUrl, profile, onRefresh, hideAmounts = false, onLogs, headerActions = null, panel, finder, wrap = (frame) => frame, children, }) {
91
+ export function LumenShell({ app, appLabel, brand, nav, status, health, statusEnd, releases, settings, shared, sharedLabel, user = null, signOutUrl, profile, security, onRefresh, hideAmounts = false, onLogs, headerActions = null, panel, finder, wrap = (frame) => frame, children, }) {
92
92
  const [statusSlot, setStatusSlot] = useState(null);
93
93
  const [refreshing, setRefreshing] = useState(false);
94
94
  // Refresh reloads the layout too: once the data is fresh the page mounts
@@ -117,7 +117,7 @@ export function LumenShell({ app, appLabel, brand, nav, status, health, statusEn
117
117
  document.documentElement.dataset.app = app;
118
118
  }, [app]);
119
119
  // Find lists every Settings section by name.
120
- const sectionNames = JSON.stringify(allSettings(profile, shared, settings).map((s) => ({ id: s.id, title: s.title })));
120
+ const sectionNames = JSON.stringify(allSettings(profile, shared, settings, security).map((s) => ({ id: s.id, title: s.title })));
121
121
  const findSettings = useMemo(() => JSON.parse(sectionNames), [sectionNames]);
122
122
  return (_jsx(Ctx.Provider, { value: {
123
123
  app,
@@ -129,7 +129,7 @@ export function LumenShell({ app, appLabel, brand, nav, status, health, statusEn
129
129
  refreshing,
130
130
  hideAmounts,
131
131
  headerActions,
132
- }, children: _jsx(SourceHealthProvider, { children: wrap(_jsx(TooltipProvider, { delayDuration: 200, children: _jsx(SidebarProvider, { defaultOpen: sidebarOpen(), children: _jsxs(LumenFinder, { app: app, settings: findSettings, openSettings: openSettings, config: finder, children: [_jsx(CloseDrawerOnNavigate, {}), _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: brand }), _jsx(SidebarContent, { children: nav }), _jsx(SidebarFooter, { children: _jsx(NavUser, { who: user, signOutUrl: signOutUrl, onSettings: () => openSettings(), onLogs: onLogs }) }), _jsx(SidebarRail, {})] }), _jsxs(SidebarInset, { className: "flex min-h-svh min-w-0 flex-col", children: [_jsx("div", { className: "flex min-w-0 flex-1 flex-col", children: children }, epoch), _jsxs("footer", { "data-slot": "shell-status", "aria-label": "Status", className: "sticky bottom-0 z-10 flex h-(--shell-status-height) shrink-0 items-center gap-4 overflow-x-auto overflow-y-hidden border-t bg-background px-(--shell-gutter) text-xs whitespace-nowrap text-muted-foreground", children: [status, _jsx("div", { ref: setStatusSlot, className: "flex shrink-0 items-center gap-4" }), _jsxs("span", { className: "ml-auto flex shrink-0 items-center gap-1", children: [health, releases && _jsx(WhatsNew, { api: releases }), statusEnd, _jsx(StatusTimeZone, {})] })] })] }), panel, _jsx(LumenSettings, { open: settingsAt !== null, section: settingsAt ?? lastSection, onOpen: openSettings, onClose: () => setSettingsAt(null), appLabel: appLabel, profile: profile, sections: settings, shared: shared, sharedLabel: sharedLabel }), _jsx(Toaster, { position: "bottom-right" })] }) }) })) }) }));
132
+ }, children: _jsx(SourceHealthProvider, { children: wrap(_jsx(TooltipProvider, { delayDuration: 200, children: _jsx(SidebarProvider, { defaultOpen: sidebarOpen(), children: _jsxs(LumenFinder, { app: app, settings: findSettings, openSettings: openSettings, config: finder, children: [_jsx(CloseDrawerOnNavigate, {}), _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: brand }), _jsx(SidebarContent, { children: nav }), _jsx(SidebarFooter, { children: _jsx(NavUser, { who: user, signOutUrl: signOutUrl, onSettings: () => openSettings(), onLogs: onLogs }) }), _jsx(SidebarRail, {})] }), _jsxs(SidebarInset, { className: "flex min-h-svh min-w-0 flex-col", children: [_jsx("div", { className: "flex min-w-0 flex-1 flex-col", children: children }, epoch), _jsxs("footer", { "data-slot": "shell-status", "aria-label": "Status", className: "sticky bottom-0 z-10 flex h-(--shell-status-height) shrink-0 items-center gap-4 overflow-x-auto overflow-y-hidden border-t bg-background px-(--shell-gutter) text-xs whitespace-nowrap text-muted-foreground", children: [status, _jsx("div", { ref: setStatusSlot, className: "flex shrink-0 items-center gap-4" }), _jsxs("span", { className: "ml-auto flex shrink-0 items-center gap-1", children: [health, releases && _jsx(WhatsNew, { api: releases }), statusEnd, _jsx(StatusTimeZone, {})] })] })] }), panel, _jsx(LumenSettings, { open: settingsAt !== null, section: settingsAt ?? lastSection, onOpen: openSettings, onClose: () => setSettingsAt(null), appLabel: appLabel, profile: profile, security: security, sections: settings, shared: shared, sharedLabel: sharedLabel }), _jsx(Toaster, { position: "bottom-right" })] }) }) })) }) }));
133
133
  }
134
134
  /** A status bar entry: an optional health dot, a label and a mono value. */
135
135
  export function StatusItem({ label, value, ok, className, }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,7 +41,7 @@
41
41
  "@codemirror/lang-javascript": "6.2.5",
42
42
  "@codemirror/lang-json": "6.0.2",
43
43
  "@codemirror/lang-sql": "6.10.0",
44
- "@columenlabs/lumen-ui-types": "0.1.17",
44
+ "@columenlabs/lumen-ui-types": "0.1.19",
45
45
  "@dnd-kit/core": "6.3.1",
46
46
  "@dnd-kit/sortable": "10.0.0",
47
47
  "@dnd-kit/utilities": "3.2.2",
@@ -62,6 +62,7 @@
62
62
  "lightweight-charts": "^5.2.1",
63
63
  "lucide-react": "^1.48.0",
64
64
  "papaparse": "5.5.3",
65
+ "qrcode.react": "4.2.0",
65
66
  "radix-ui": "^1.6.7",
66
67
  "react-error-boundary": "^6.1.6",
67
68
  "react-grid-layout": "2.2.4",