Rewrite the expression editor, two-factor setup and object picker #131

Merged
jcoffey-dev merged 1 commits from phase4/editors into main 2026-10-19 04:17:41 +00:00
7 changed files with 446 additions and 291 deletions

No files matched your search

+37 -39
View File
@@ -1,7 +1,7 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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
<div className="flex items-center gap-2 flex-wrap">
{value && (
<Badge variant="secondary" className="gap-1 pr-1.5 text-sm max-w-xs">
{labelLoading ? (
{looked.loading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<span className="truncate" title={display ?? value}>
{display ?? value}
<span className="truncate" title={name}>
{name}
</span>
)}
{onClear && (
@@ -69,39 +72,33 @@ export function ObjectPicker({ schema, objectName, value, onChange, onClear, pla
</Badge>
)}
{!value && placeholder && <span className="text-sm text-muted-foreground">{placeholder}</span>}
{noPermissionMessage ? (
{noPermission ? (
<TooltipProvider>
<Tooltip open={tooltipOpen} onOpenChange={setTooltipOpen}>
<Tooltip open={whyOpen} onOpenChange={setWhyOpen}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 cursor-not-allowed opacity-60"
aria-label={t('common.search', 'Search')}
aria-label={searchLabel}
aria-disabled="true"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setTooltipOpen(true);
setWhyOpen(true);
}}
>
<Search className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{noPermissionMessage}</TooltipContent>
<TooltipContent>{noPermission}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Popover open={open} onOpenChange={handleOpenChange}>
<Popover open={open} onOpenChange={openChange}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label={t('common.search', 'Search')}
>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" aria-label={searchLabel}>
<Search className="h-4 w-4" />
</Button>
</PopoverTrigger>
@@ -109,26 +106,27 @@ export function ObjectPicker({ schema, objectName, value, onChange, onClear, pla
<Command>
<CommandInput placeholder={t('common.searchPlaceholder', 'Search...')} />
<CommandList>
{list.loading && (
{list.loading ? (
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('common.loading', 'Loading...')}
</div>
) : (
<CommandEmpty>{t('common.noResultsDot', 'No results.')}</CommandEmpty>
)}
{!list.loading && <CommandEmpty>{t('common.noResultsDot', 'No results.')}</CommandEmpty>}
<CommandGroup>
{list.options.map((opt) => (
{list.options.map((option) => (
<CommandItem
key={opt.id}
value={opt.id}
keywords={[opt.label]}
key={option.id}
value={option.id}
keywords={[option.label]}
onSelect={() => {
onChange(opt.id);
onChange(option.id);
setOpen(false);
}}
>
<span className="truncate" title={opt.label}>
{opt.label}
<span className="truncate" title={option.label}>
{option.label}
</span>
</CommandItem>
))}
+56 -101
View File
@@ -1,43 +1,45 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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<HTMLInputElement>,
type TextInputProps = { value: string; onCommit: (text: string) => void } & Omit<
InputHTMLAttributes<HTMLInputElement>,
'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 (
<Input
{...rest}
value={local}
onChange={(e) => 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<string, { if: string; then: string }>;
else: string;
};
onChange: (value: { match: Record<string, { if: string; then: string }>; 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 (
<div className="rounded-lg border bg-card p-4 space-y-3">
{matchEntries.map(([key, entry], index) => (
{conditions.map(([key, condition], i) => (
<div key={key}>
<div className="rounded-md border bg-muted/30 p-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground w-12 shrink-0">
{t('expression.if', 'IF')}
</span>
<BufferedExprInput
value={entry.if}
onCommit={(v) => handleConditionChange(key, 'if', v)}
<span className={LABEL}>{t('expression.if', 'IF')}</span>
<CommittingInput
value={condition.if}
onCommit={(text) => 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))}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground w-12 shrink-0">
{t('expression.then', 'THEN')}
</span>
<BufferedExprInput
value={entry.then}
onCommit={(v) => handleConditionChange(key, 'then', v)}
<span className={LABEL}>{t('expression.then', 'THEN')}</span>
<CommittingInput
value={condition.then}
onCommit={(text) => 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 && <div className="w-8 shrink-0" />}
</div>
</div>
{index < matchEntries.length - 1 && <Separator className="my-3" />}
{i < conditions.length - 1 && <Separator className="my-3" />}
</div>
))}
{!readOnly && (
<div className={hints ? 'flex justify-between' : 'flex justify-end'}>
{hints && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setAsText(false)}
className="h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<Button type="button" variant="ghost" size="sm" onClick={() => setAsText(false)} className={SMALL_ACTION}>
<ListChecks className="h-3 w-3" />
{t('expression.editAsChoices', 'Edit as choices')}
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleAdd}
className="h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<Button type="button" variant="ghost" size="sm" onClick={add} className={SMALL_ACTION}>
<Plus className="h-3 w-3" />
{t('expression.addCondition', 'Add condition')}
</Button>
</div>
)}
{matchEntries.length > 0 && <Separator />}
{hasConditions && <Separator />}
<div className="flex items-center gap-2">
{matchEntries.length > 0 && (
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground w-12 shrink-0">
{t('expression.else', 'ELSE')}
</span>
)}
<BufferedExprInput
{hasConditions && <span className={LABEL}>{t('expression.else', 'ELSE')}</span>}
<CommittingInput
value={value.else}
onCommit={handleElseChange}
onCommit={(text) => 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 && <div className="w-8 shrink-0" />}
{!readOnly && hasConditions && <div className="w-8 shrink-0" />}
</div>
</div>
);
@@ -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'" });
});
});
+44
View File
@@ -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<string, Condition>;
else: string;
}
/** The key a new condition gets: one past the highest numbered key, so none is reused. */
export function nextConditionKey(match: Record<string, Condition>): 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 };
}
+114 -151
View File
@@ -1,15 +1,12 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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 (
<Input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
value={value}
onChange={(e) => 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<OTPAuth.TOTP | null>(null);
const [setupUrl, setSetupUrl] = useState<string | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [setup, setSetup] = useState<Setup | null>(null);
const [qr, setQr] = useState<string | null>(null);
const [setupCode, setSetupCode] = useState('');
const [setupError, setSetupError] = useState<string | null>(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 (
<div className="text-sm text-muted-foreground">
{isConfigured
{configured
? t('otp.statusEnabled', 'Two-factor authentication is enabled.')
: t('otp.statusDisabled', 'Two-factor authentication is not enabled.')}
</div>
);
}
if (!isConfigured && setupUrl) {
if (!configured && setup) {
return (
<div className="rounded-md border bg-muted/30 p-4 space-y-4">
<div className="space-y-2">
@@ -166,9 +141,9 @@ export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) {
</p>
</div>
<div className="flex justify-center">
{qrDataUrl ? (
{qr ? (
<img
src={qrDataUrl}
src={qr}
alt={t('otp.qrCodeAlt', 'TOTP QR code')}
width={220}
height={220}
@@ -180,45 +155,36 @@ export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) {
</div>
)}
</div>
{setupSecret && (
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t('otp.manualEntryLabel', 'Or enter this code manually')}</Label>
<p className="text-xs text-muted-foreground">
{t(
'otp.manualEntryDescription',
'If you cannot scan the QR code, enter this secret into your authenticator app instead.',
)}
</p>
<div className="flex gap-2">
<code className="flex-1 rounded bg-muted p-2 text-sm font-mono break-all select-all">{setupSecret}</code>
<Button
type="button"
variant="outline"
size="sm"
onClick={copySecret}
aria-label={t('otp.copySecret', 'Copy secret')}
>
{secretCopied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t('otp.manualEntryLabel', 'Or enter this code manually')}</Label>
<p className="text-xs text-muted-foreground">
{t(
'otp.manualEntryDescription',
'If you cannot scan the QR code, enter this secret into your authenticator app instead.',
)}
</p>
<div className="flex gap-2">
<code className="flex-1 rounded bg-muted p-2 text-sm font-mono break-all select-all">
{groupedSecret(setup.totp.secret.base32)}
</code>
<Button
type="button"
variant="outline"
size="sm"
onClick={copySecret}
aria-label={t('otp.copySecret', 'Copy secret')}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
)}
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t('otp.confirmationCodeLabel', 'Confirmation code')}</Label>
<Input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
value={setupCode}
onChange={(e) => setSetupCode(e.target.value.replace(/\s+/g, ''))}
maxLength={10}
className="font-mono tracking-widest text-center max-w-[12rem]"
/>
<CodeInput value={setupCode} onChange={setSetupCode} />
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
</div>
<div className="flex items-center justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={cancelSetup}>
<Button type="button" variant="outline" size="sm" onClick={endSetup}>
{t('common.cancel', 'Cancel')}
</Button>
<Button type="button" size="sm" onClick={confirmSetup}>
@@ -230,7 +196,7 @@ export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) {
);
}
if (!isConfigured) {
if (!configured) {
return (
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
@@ -258,19 +224,16 @@ export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) {
</p>
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t('otp.currentCodeLabel', 'Current code')}</Label>
<Input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
value={otpCodeValue}
onChange={(e) => handleCodeChange(e.target.value.replace(/\s+/g, ''))}
maxLength={10}
className="font-mono tracking-widest text-center max-w-[12rem]"
/>
<CodeInput value={code} onChange={(otpCode) => onChange({ ...current, otpCode })} />
</div>
<div className="flex items-center justify-end">
<Button type="button" variant="destructive" size="sm" onClick={handleDisable} disabled={!otpCodeValue.trim()}>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => onChange({ ...current, otpUrl: null })}
disabled={!code.trim()}
>
<ShieldOff className="mr-2 h-4 w-4" />
{t('otp.disable', 'Disable')}
</Button>
+82
View File
@@ -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: '' });
});
});
+55
View File
@@ -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 : '',
};
}