Rewrite the expression editor, two-factor setup and object picker #131
No files matched your search
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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'" });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '' });
|
||||
});
|
||||
});
|
||||
@@ -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 : '',
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user