diff --git a/src/components/common/ObjectPicker.tsx b/src/components/common/ObjectPicker.tsx index 30f8caf..64aaad5 100644 --- a/src/components/common/ObjectPicker.tsx +++ b/src/components/common/ObjectPicker.tsx @@ -1,7 +1,7 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * SPDX-License-Identifier: AGPL-3.0-only */ import { useState } from 'react'; @@ -10,11 +10,11 @@ import { Loader2, Search, X } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { useObjectList, useObjectLabel, useNoPermissionMessage } from '@/lib/objectOptions'; +import { useNoPermissionMessage, useObjectLabel, useObjectList } from '@/lib/objectOptions'; import type { Schema } from '@/types/schema'; interface ObjectPickerProps { @@ -26,21 +26,24 @@ interface ObjectPickerProps { placeholder?: string; } +/** + * One object chosen by searching a list of them, shown as a chip with its + * name. The list loads the first time it opens; without permission to read + * the objects, the search button only says why it can't open. + */ export function ObjectPicker({ schema, objectName, value, onChange, onClear, placeholder }: ObjectPickerProps) { const { t } = useTranslation(); const list = useObjectList(objectName, schema); - const fromList = list.options.find((o) => o.id === value)?.label; - const { label: cheapLabel, loading: labelLoading } = useObjectLabel( - objectName, - fromList ? null : value || null, - schema, - ); - const display = fromList ?? cheapLabel; + const listed = list.options.find((o) => o.id === value)?.label; + // Looked up on its own only when the list hasn't been loaded or lacks it. + const looked = useObjectLabel(objectName, listed ? null : value || null, schema); + const name = listed ?? looked.label ?? value; const [open, setOpen] = useState(false); - const [tooltipOpen, setTooltipOpen] = useState(false); - const noPermissionMessage = useNoPermissionMessage(schema, objectName); + const [whyOpen, setWhyOpen] = useState(false); + const noPermission = useNoPermissionMessage(schema, objectName); + const searchLabel = t('common.search', 'Search'); - const handleOpenChange = (next: boolean) => { + const openChange = (next: boolean) => { setOpen(next); if (next) void list.ensureLoaded(); }; @@ -49,11 +52,11 @@ export function ObjectPicker({ schema, objectName, value, onChange, onClear, pla
{value && ( - {labelLoading ? ( + {looked.loading ? ( ) : ( - - {display ?? value} + + {name} )} {onClear && ( @@ -69,39 +72,33 @@ export function ObjectPicker({ schema, objectName, value, onChange, onClear, pla )} {!value && placeholder && {placeholder}} - {noPermissionMessage ? ( + {noPermission ? ( - + - {noPermissionMessage} + {noPermission} ) : ( - + - @@ -109,26 +106,27 @@ export function ObjectPicker({ schema, objectName, value, onChange, onClear, pla - {list.loading && ( + {list.loading ? (
{t('common.loading', 'Loading...')}
+ ) : ( + {t('common.noResultsDot', 'No results.')} )} - {!list.loading && {t('common.noResultsDot', 'No results.')}} - {list.options.map((opt) => ( + {list.options.map((option) => ( { - onChange(opt.id); + onChange(option.id); setOpen(false); }} > - - {opt.label} + + {option.label} ))} diff --git a/src/components/expression/ExpressionEditor.tsx b/src/components/expression/ExpressionEditor.tsx index d294264..7e484c1 100644 --- a/src/components/expression/ExpressionEditor.tsx +++ b/src/components/expression/ExpressionEditor.tsx @@ -1,43 +1,45 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ -import { useState } from 'react'; +import { useState, type InputHTMLAttributes } from 'react'; import { useTranslation } from 'react-i18next'; +import { ListChecks, Plus, X } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; import { useBufferedValue } from '@/hooks/useBufferedValue'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { Separator } from '@/components/ui/separator'; - -import { X, Plus, ListChecks } from 'lucide-react'; +import { + nextConditionKey, + withAddedCondition, + withConditionPart, + withElse, + withoutCondition, + type ExpressionValue, +} from './conditions'; import { SimpleExpressionEditor } from './SimpleExpressionEditor'; import type { ExpressionHints } from './simple'; -function BufferedExprInput({ - value, - onCommit, - ...rest -}: { value: string; onCommit: (v: string) => void } & Omit< - React.InputHTMLAttributes, +type TextInputProps = { value: string; onCommit: (text: string) => void } & Omit< + InputHTMLAttributes, 'onChange' | 'value' ->) { - const [local, setLocal] = useBufferedValue(value); +>; +/** An input that hands its text on when left or on Enter, not at every key. */ +function CommittingInput({ value, onCommit, ...rest }: TextInputProps) { + const [text, setText] = useBufferedValue(value); const commit = () => { - if (local !== value) onCommit(local); + if (text !== value) onCommit(text); }; - return ( setLocal(e.target.value)} + value={text} + onChange={(e) => setText(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === 'Enter') commit(); @@ -46,23 +48,21 @@ function BufferedExprInput({ ); } +const LABEL = 'text-xs font-semibold uppercase tracking-wider text-muted-foreground w-12 shrink-0'; +const SMALL_ACTION = 'h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground'; + interface ExpressionEditorProps { - value: { - match: Record; - else: string; - }; - onChange: (value: { match: Record; else: string }) => void; + value: ExpressionValue; + onChange: (value: ExpressionValue) => void; readOnly?: boolean; - /** inbuxa: what the field accepts, from the schema; with it, the field opens in plain form. */ + /** What the field accepts, from the schema; with it, the field opens as choices. */ hints?: ExpressionHints; } +/** An expression as IF/THEN conditions and an ELSE, or as choices when the schema says what fits. */ export function ExpressionEditor({ value, onChange, readOnly = false, hints }: ExpressionEditorProps) { const { t } = useTranslation(); - const [nextIndex, setNextIndex] = useState(() => { - const keys = Object.keys(value.match).map(Number).filter(Number.isFinite); - return keys.length > 0 ? Math.max(...keys) + 1 : 0; - }); + const [nextKey, setNextKey] = useState(() => nextConditionKey(value.match)); const [asText, setAsText] = useState(false); if (hints && !asText) { @@ -77,49 +77,24 @@ export function ExpressionEditor({ value, onChange, readOnly = false, hints }: E ); } - const matchEntries = Object.entries(value.match); + const conditions = Object.entries(value.match); + const hasConditions = conditions.length > 0; - const handleConditionChange = (key: string, field: 'if' | 'then', fieldValue: string) => { - onChange({ - ...value, - match: { - ...value.match, - [key]: { ...value.match[key], [field]: fieldValue }, - }, - }); - }; - - const handleElseChange = (elseValue: string) => { - onChange({ ...value, else: elseValue }); - }; - - const handleAdd = () => { - const key = String(nextIndex); - setNextIndex(nextIndex + 1); - onChange({ - ...value, - match: { ...value.match, [key]: { if: '', then: '' } }, - }); - }; - - const handleRemove = (key: string) => { - const { [key]: _removed, ...rest } = value.match; - void _removed; - onChange({ ...value, match: rest }); + const add = () => { + setNextKey(nextKey + 1); + onChange(withAddedCondition(value, String(nextKey))); }; return (
- {matchEntries.map(([key, entry], index) => ( + {conditions.map(([key, condition], i) => (
- - {t('expression.if', 'IF')} - - handleConditionChange(key, 'if', v)} + {t('expression.if', 'IF')} + onChange(withConditionPart(value, key, 'if', text))} disabled={readOnly} placeholder={t('expression.condition', 'Condition')} className="flex-1" @@ -130,19 +105,17 @@ export function ExpressionEditor({ value, onChange, readOnly = false, hints }: E variant="ghost" size="icon" className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive" - onClick={() => handleRemove(key)} + onClick={() => onChange(withoutCondition(value, key))} > )}
- - {t('expression.then', 'THEN')} - - handleConditionChange(key, 'then', v)} + {t('expression.then', 'THEN')} + onChange(withConditionPart(value, key, 'then', text))} disabled={readOnly} placeholder={t('expression.result', 'Result')} className="flex-1" @@ -150,54 +123,36 @@ export function ExpressionEditor({ value, onChange, readOnly = false, hints }: E {!readOnly &&
}
- {index < matchEntries.length - 1 && } + {i < conditions.length - 1 && }
))} {!readOnly && (
{hints && ( - )} -
)} - {matchEntries.length > 0 && } + {hasConditions && }
- {matchEntries.length > 0 && ( - - {t('expression.else', 'ELSE')} - - )} - {t('expression.else', 'ELSE')}} + onChange(withElse(value, text))} disabled={readOnly} - placeholder={ - matchEntries.length > 0 ? t('expression.defaultValue', 'Default value') : t('expression.value', 'Value') - } + placeholder={hasConditions ? t('expression.defaultValue', 'Default value') : t('expression.value', 'Value')} className="flex-1" /> - {!readOnly && matchEntries.length > 0 &&
} + {!readOnly && hasConditions &&
}
); diff --git a/src/components/expression/conditions.test.ts b/src/components/expression/conditions.test.ts new file mode 100644 index 0000000..b3cf798 --- /dev/null +++ b/src/components/expression/conditions.test.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { + nextConditionKey, + withAddedCondition, + withConditionPart, + withElse, + withoutCondition, + type ExpressionValue, +} from './conditions'; + +const value: ExpressionValue = { + match: { '0': { if: 'is_tls', then: "'a'" }, '3': { if: 'local_port == 25', then: "'b'" } }, + else: "'c'", +}; + +describe('nextConditionKey', () => { + it('starts at 0 and goes one past the highest numbered key', () => { + expect(nextConditionKey({})).toBe(0); + expect(nextConditionKey(value.match)).toBe(4); + }); + + it('ignores keys that are not numbers', () => { + expect(nextConditionKey({ x: { if: '', then: '' } })).toBe(0); + expect(nextConditionKey({ x: { if: '', then: '' }, '7': { if: '', then: '' } })).toBe(8); + }); +}); + +describe('edits', () => { + it('changes one side of one condition', () => { + const next = withConditionPart(value, '3', 'then', "'z'"); + expect(next.match['3']).toEqual({ if: 'local_port == 25', then: "'z'" }); + expect(next.match['0']).toBe(value.match['0']); + expect(next.else).toBe("'c'"); + expect(value.match['3'].then).toBe("'b'"); + }); + + it('adds an empty condition at the end', () => { + const next = withAddedCondition(value, '4'); + expect(Object.keys(next.match)).toEqual(['0', '3', '4']); + expect(next.match['4']).toEqual({ if: '', then: '' }); + }); + + it('removes a condition and keeps the others in order', () => { + const next = withoutCondition(withAddedCondition(value, '4'), '3'); + expect(Object.keys(next.match)).toEqual(['0', '4']); + expect(Object.keys(value.match)).toEqual(['0', '3']); + }); + + it('sets the value used when nothing matches', () => { + expect(withElse(value, "'d'")).toEqual({ ...value, else: "'d'" }); + }); +}); diff --git a/src/components/expression/conditions.ts b/src/components/expression/conditions.ts new file mode 100644 index 0000000..8baf233 --- /dev/null +++ b/src/components/expression/conditions.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** Edits to an expression's IF/THEN list and its ELSE, each giving a new value. */ + +export interface Condition { + if: string; + then: string; +} + +export interface ExpressionValue { + match: Record; + else: string; +} + +/** The key a new condition gets: one past the highest numbered key, so none is reused. */ +export function nextConditionKey(match: Record): number { + const numbered = Object.keys(match).map(Number).filter(Number.isFinite); + return numbered.length > 0 ? Math.max(...numbered) + 1 : 0; +} + +export function withConditionPart( + value: ExpressionValue, + key: string, + part: keyof Condition, + text: string, +): ExpressionValue { + return { ...value, match: { ...value.match, [key]: { ...value.match[key], [part]: text } } }; +} + +export function withAddedCondition(value: ExpressionValue, key: string): ExpressionValue { + return { ...value, match: { ...value.match, [key]: { if: '', then: '' } } }; +} + +export function withoutCondition(value: ExpressionValue, key: string): ExpressionValue { + return { ...value, match: Object.fromEntries(Object.entries(value.match).filter(([k]) => k !== key)) }; +} + +export function withElse(value: ExpressionValue, text: string): ExpressionValue { + return { ...value, else: text }; +} diff --git a/src/components/forms/OtpAuthField.tsx b/src/components/forms/OtpAuthField.tsx index 463ca52..01d27a6 100644 --- a/src/components/forms/OtpAuthField.tsx +++ b/src/components/forms/OtpAuthField.tsx @@ -1,15 +1,12 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import * as OTPAuth from 'otpauth'; +import type * as OTPAuth from 'otpauth'; import QRCode from 'qrcode'; import { Check, Copy, Loader2, ShieldCheck, ShieldOff } from 'lucide-react'; @@ -17,12 +14,8 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { toast } from '@/hooks/use-toast'; -import { SECRET_MASK } from '@/lib/jmapUtils'; -interface OtpAuthValue { - otpUrl?: string | null; - otpCode?: string | null; -} +import { checkSetupCode, groupedSecret, newTotp, readOtp, withoutSpaces } from './otp'; interface OtpAuthFieldProps { value: unknown; @@ -30,47 +23,92 @@ interface OtpAuthFieldProps { readOnly: boolean; } -function buildOtpAuthUrl(totp: OTPAuth.TOTP): string { - // No `image` parameter: it made authenticator apps fetch a logo from a - // third-party site each time someone set up two-factor. - return totp.toString(); +interface Setup { + totp: OTPAuth.TOTP; + url: string; } -function generateTotp(): { totp: OTPAuth.TOTP; url: string } { - const totp = new OTPAuth.TOTP({ - issuer: 'inbuxa', - label: 'account', - algorithm: 'SHA1', - digits: 6, - period: 30, - secret: new OTPAuth.Secret({ size: 20 }), - }); - return { totp, url: buildOtpAuthUrl(totp) }; +const CODE_INPUT = 'font-mono tracking-widest text-center max-w-[12rem]'; + +function CodeInput({ value, onChange }: { value: string; onChange: (code: string) => void }) { + return ( + onChange(withoutSpaces(e.target.value))} + maxLength={10} + className={CODE_INPUT} + /> + ); } +/** + * Two-factor sign-in for an account. Turning it on shows a new secret as a + * QR code and as text, and takes it only once the app's first code checks + * out; once on, any change to the account needs a current code. + */ export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) { const { t } = useTranslation(); - const objValue = (value as OtpAuthValue | null | undefined) ?? {}; - const isConfigured = objValue.otpUrl != null && objValue.otpUrl !== ''; + const { configured, code } = readOtp(value); + const current = (value ?? {}) as object; - const [setupTotp, setSetupTotp] = useState(null); - const [setupUrl, setSetupUrl] = useState(null); - const [qrDataUrl, setQrDataUrl] = useState(null); + const [setup, setSetup] = useState(null); + const [qr, setQr] = useState(null); const [setupCode, setSetupCode] = useState(''); const [setupError, setSetupError] = useState(null); - const [secretCopied, setSecretCopied] = useState(false); + const [copied, setCopied] = useState(false); - const setupSecret = useMemo(() => { - if (!setupTotp) return null; - return setupTotp.secret.base32.replace(/(.{4})/g, '$1 ').trim(); - }, [setupTotp]); + const setupUrl = setup?.url ?? null; + useEffect(() => { + if (!setupUrl) return; + let live = true; + QRCode.toDataURL(setupUrl, { width: 220, margin: 1 }) + .then((dataUrl) => { + if (live) setQr(dataUrl); + }) + .catch((err) => console.error('Failed to render QR code', err)); + return () => { + live = false; + }; + }, [setupUrl]); + + const endSetup = () => { + setSetup(null); + setSetupCode(''); + setSetupError(null); + setCopied(false); + }; + + const startSetup = () => { + setSetup(newTotp()); + setSetupCode(''); + setSetupError(null); + }; + + const confirmSetup = () => { + if (!setup) return; + switch (checkSetupCode(setup.totp, setupCode)) { + case 'missing': + setSetupError(t('otp.enterCodePrompt', 'Enter the code shown in your authenticator.')); + return; + case 'wrong': + setSetupError(t('otp.codeIncorrect', 'That code is incorrect. Make sure your authenticator clock is in sync.')); + return; + case 'ok': + onChange({ otpUrl: setup.url, otpCode: setupCode.trim() }); + endSetup(); + } + }; const copySecret = async () => { - if (!setupTotp) return; + if (!setup) return; try { - await navigator.clipboard.writeText(setupTotp.secret.base32); - setSecretCopied(true); - setTimeout(() => setSecretCopied(false), 1500); + await navigator.clipboard.writeText(setup.totp.secret.base32); + setCopied(true); + setTimeout(() => setCopied(false), 1500); } catch { toast({ title: t('otp.copyFailed', 'Copy failed'), @@ -80,80 +118,17 @@ export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) { } }; - useEffect(() => { - if (!setupUrl) return; - let cancelled = false; - QRCode.toDataURL(setupUrl, { width: 220, margin: 1 }) - .then((dataUrl) => { - if (!cancelled) setQrDataUrl(dataUrl); - }) - .catch((err) => { - console.error('Failed to render QR code', err); - }); - return () => { - cancelled = true; - }; - }, [setupUrl]); - - const startSetup = () => { - const { totp, url } = generateTotp(); - setSetupTotp(totp); - setSetupUrl(url); - setSetupCode(''); - setSetupError(null); - }; - - const confirmSetup = () => { - if (!setupTotp || !setupUrl) return; - if (!setupCode.trim()) { - setSetupError(t('otp.enterCodePrompt', 'Enter the code shown in your authenticator.')); - return; - } - const delta = setupTotp.validate({ token: setupCode.trim(), window: 1 }); - if (delta === null) { - setSetupError(t('otp.codeIncorrect', 'That code is incorrect. Make sure your authenticator clock is in sync.')); - return; - } - setSetupError(null); - onChange({ otpUrl: setupUrl, otpCode: setupCode.trim() }); - setSetupTotp(null); - setSetupUrl(null); - setSetupCode(''); - setSecretCopied(false); - }; - - const cancelSetup = () => { - setSetupTotp(null); - setSetupUrl(null); - setSetupCode(''); - setSetupError(null); - setSecretCopied(false); - }; - - const otpCodeValue = useMemo( - () => (typeof objValue.otpCode === 'string' && objValue.otpCode !== SECRET_MASK ? objValue.otpCode : ''), - [objValue.otpCode], - ); - - const handleCodeChange = (code: string) => { - onChange({ ...objValue, otpCode: code }); - }; - - const handleDisable = () => { - onChange({ ...objValue, otpUrl: null }); - }; - if (readOnly) { return (
- {isConfigured + {configured ? t('otp.statusEnabled', 'Two-factor authentication is enabled.') : t('otp.statusDisabled', 'Two-factor authentication is not enabled.')}
); } - if (!isConfigured && setupUrl) { + if (!configured && setup) { return (
@@ -166,9 +141,9 @@ export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) {

- {qrDataUrl ? ( + {qr ? ( {t('otp.qrCodeAlt', )}
- {setupSecret && ( -
- -

- {t( - 'otp.manualEntryDescription', - 'If you cannot scan the QR code, enter this secret into your authenticator app instead.', - )} -

-
- {setupSecret} - -
+
+ +

+ {t( + 'otp.manualEntryDescription', + 'If you cannot scan the QR code, enter this secret into your authenticator app instead.', + )} +

+
+ + {groupedSecret(setup.totp.secret.base32)} + +
- )} +
- setSetupCode(e.target.value.replace(/\s+/g, ''))} - maxLength={10} - className="font-mono tracking-widest text-center max-w-[12rem]" - /> + {setupError &&

{setupError}

}
- diff --git a/src/components/forms/otp.test.ts b/src/components/forms/otp.test.ts new file mode 100644 index 0000000..2a5d92a --- /dev/null +++ b/src/components/forms/otp.test.ts @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, describe, it, expect, vi } from 'vitest'; +import * as OTPAuth from 'otpauth'; +import { checkSetupCode, groupedSecret, newTotp, readOtp, withoutSpaces } from './otp'; + +afterEach(() => vi.useRealTimers()); + +describe('newTotp', () => { + it('makes a six-digit, 30-second SHA-1 secret of 20 bytes', () => { + const { totp, url } = newTotp(); + expect(totp.secret.bytes).toHaveLength(20); + expect(totp.secret.base32).toMatch(/^[A-Z2-7]{32}$/); + const parsed = OTPAuth.URI.parse(url) as OTPAuth.TOTP; + expect(parsed).toBeInstanceOf(OTPAuth.TOTP); + expect(parsed).toMatchObject({ issuer: 'inbuxa', label: 'account', algorithm: 'SHA1', digits: 6, period: 30 }); + expect(parsed.secret.base32).toBe(totp.secret.base32); + }); + + it('gives a link with no image to fetch', () => { + expect(newTotp().url).toMatch(/^otpauth:\/\/totp\/inbuxa:account\?/); + expect(newTotp().url).not.toContain('image'); + }); + + it('makes a different secret each time', () => { + expect(newTotp().totp.secret.base32).not.toBe(newTotp().totp.secret.base32); + }); +}); + +describe('groupedSecret', () => { + it('splits the secret into groups of four', () => { + expect(groupedSecret('AMFBCGA7EYWTIO2CJFIFOXTFNRZXVAMI')).toBe('AMFB CGA7 EYWT IO2C JFIF OXTF NRZX VAMI'); + expect(groupedSecret('ABCDEF')).toBe('ABCD EF'); + expect(groupedSecret('')).toBe(''); + }); +}); + +describe('checkSetupCode', () => { + const totp = new OTPAuth.TOTP({ secret: OTPAuth.Secret.fromBase32('AMFBCGA7EYWTIO2CJFIFOXTFNRZXVAMI') }); + + it('asks for a code when none was typed', () => { + expect(checkSetupCode(totp, '')).toBe('missing'); + expect(checkSetupCode(totp, ' ')).toBe('missing'); + }); + + it('takes the current code, and the ones a period either side', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-18T19:00:07Z')); + expect(checkSetupCode(totp, '344939')).toBe('ok'); + expect(checkSetupCode(totp, ' 344939 ')).toBe('ok'); + const at = (s: number) => totp.generate({ timestamp: Date.now() + s * 1000 }); + expect(checkSetupCode(totp, at(-30))).toBe('ok'); + expect(checkSetupCode(totp, at(30))).toBe('ok'); + expect(checkSetupCode(totp, at(90))).toBe('wrong'); + expect(checkSetupCode(totp, '000000')).toBe('wrong'); + }); +}); + +describe('withoutSpaces', () => { + it('drops every space', () => { + expect(withoutSpaces(' 344 939\t')).toBe('344939'); + }); +}); + +describe('readOtp', () => { + it('is off without a link', () => { + expect(readOtp(undefined)).toEqual({ configured: false, code: '' }); + expect(readOtp(null)).toEqual({ configured: false, code: '' }); + expect(readOtp({ otpUrl: null })).toEqual({ configured: false, code: '' }); + expect(readOtp({ otpUrl: '' })).toEqual({ configured: false, code: '' }); + }); + + it('is on with a link, showing the code typed but never the mask', () => { + expect(readOtp({ otpUrl: 'otpauth://totp/x', otpCode: '****' })).toEqual({ configured: true, code: '' }); + expect(readOtp({ otpUrl: 'otpauth://totp/x', otpCode: '12' })).toEqual({ configured: true, code: '12' }); + expect(readOtp({ otpUrl: '****', otpCode: null })).toEqual({ configured: true, code: '' }); + }); +}); diff --git a/src/components/forms/otp.ts b/src/components/forms/otp.ts new file mode 100644 index 0000000..c943e7c --- /dev/null +++ b/src/components/forms/otp.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** Two-factor setup: a new authenticator secret, checking the first code, and reading the stored state. */ +import * as OTPAuth from 'otpauth'; +import { SECRET_MASK } from '@/lib/jmapUtils'; + +export interface OtpAuthValue { + otpUrl?: string | null; + otpCode?: string | null; +} + +/** + * A new 20-byte secret, with the otpauth:// link an authenticator app reads. + * The link carries no `image`: that would have the app fetch a logo from a + * third-party site at every setup. + */ +export function newTotp(): { totp: OTPAuth.TOTP; url: string } { + const totp = new OTPAuth.TOTP({ + issuer: 'inbuxa', + label: 'account', + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: new OTPAuth.Secret({ size: 20 }), + }); + return { totp, url: totp.toString() }; +} + +/** The secret in groups of four, easier to type into an app by hand. */ +export function groupedSecret(base32: string): string { + return base32.replace(/(.{4})/g, '$1 ').trim(); +} + +/** Whether the code typed at setup matches the new secret, allowing one period of clock drift. */ +export function checkSetupCode(totp: OTPAuth.TOTP, code: string): 'missing' | 'wrong' | 'ok' { + const token = code.trim(); + if (!token) return 'missing'; + return totp.validate({ token, window: 1 }) === null ? 'wrong' : 'ok'; +} + +/** Codes are digits; spaces people paste or type between groups are dropped. */ +export const withoutSpaces = (code: string) => code.replace(/\s+/g, ''); + +/** Whether two-factor is on, and the current code typed so far (never the server's mask). */ +export function readOtp(value: unknown): { configured: boolean; code: string } { + const v = (value as OtpAuthValue | null | undefined) ?? {}; + return { + configured: v.otpUrl != null && v.otpUrl !== '', + code: typeof v.otpCode === 'string' && v.otpCode !== SECRET_MASK ? v.otpCode : '', + }; +}