feat: T4 wave 11 – bottling form with article values and confirmation, Telegram in the app
- Bottling form: jar size and best-before from the product with source, differs-hints before saving, prefill status, water-content confirmation naming the measured value, idempotency key renewed on body change; product form gets shelf life (months) and whole-gram jar weight. - Telegram frontend: link card with step-up dialog, t.me-only deep link, status polling, unlink; system Telegram card with receiver/sender state and confirmed retry; StepUpDialog shared with the backup import. - Reauth calls never refresh-and-retry on 401 (no double lockout count); German texts for expired session and 429; service worker never caches Telegram, notification settings and system responses. - Telegram backend round 2: shared link removal on every disconnect path, lease released on shutdown, connection errors not counted as poison. - ARCHITECTURE.md §2, §6 (Annahmen 56–58), §10, §11.12, §11.13; TASKS.md T4. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
fab7f7e12f
commit
9760ce154f
73 files changed
+3633
-269
No files matched your search
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { StepUpDialog } from "./step-up-dialog";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
|
||||
function renderDialog(overrides: Partial<Parameters<typeof StepUpDialog>[0]> = {}) {
|
||||
const onConfirm = vi.fn().mockResolvedValue(undefined);
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<StepUpDialog
|
||||
open
|
||||
title="Telegram verbinden?"
|
||||
confirmLabel="Verbinden"
|
||||
needsMfa={false}
|
||||
onConfirm={onConfirm}
|
||||
onOpenChange={onOpenChange}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
return { onConfirm, onOpenChange };
|
||||
}
|
||||
|
||||
describe("StepUpDialog", () => {
|
||||
it("asks for the password only without MFA and confirms with it", async () => {
|
||||
const { onConfirm, onOpenChange } = renderDialog();
|
||||
|
||||
expect(screen.queryByLabelText("Code aus der Authenticator-App")).not.toBeInTheDocument();
|
||||
const confirm = screen.getByRole("button", { name: "Verbinden" });
|
||||
expect(confirm).toBeDisabled();
|
||||
|
||||
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim");
|
||||
await userEvent.click(confirm);
|
||||
|
||||
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith({ password: "geheim" }));
|
||||
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
|
||||
});
|
||||
|
||||
it("asks for the six-digit code with MFA and sends both", async () => {
|
||||
const { onConfirm } = renderDialog({ needsMfa: true });
|
||||
|
||||
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim");
|
||||
const confirm = screen.getByRole("button", { name: "Verbinden" });
|
||||
await userEvent.type(screen.getByLabelText("Code aus der Authenticator-App"), "12ab34");
|
||||
expect(confirm).toBeDisabled();
|
||||
await userEvent.type(screen.getByLabelText("Code aus der Authenticator-App"), "56");
|
||||
expect(confirm).toBeEnabled();
|
||||
await userEvent.click(confirm);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onConfirm).toHaveBeenCalledWith({ password: "geheim", mfaCode: "123456" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("stays open and shows the server's German reason when the proof is refused", async () => {
|
||||
const onConfirm = vi.fn().mockRejectedValue(new ApiError(401, "Das Passwort ist falsch."));
|
||||
const { onOpenChange } = renderDialog({ onConfirm });
|
||||
|
||||
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "falsch");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Das Passwort ist falsch."),
|
||||
);
|
||||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||||
expect(screen.getByLabelText("Ihr Passwort")).toHaveValue("");
|
||||
expect(screen.getByLabelText("Ihr Passwort")).toHaveFocus();
|
||||
});
|
||||
|
||||
it("shows the German too-many-attempts text on 429", async () => {
|
||||
renderDialog({ onConfirm: vi.fn().mockRejectedValue(new ApiError(429, "ThrottlerException")) });
|
||||
|
||||
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "x");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"Zu viele Versuche. Bitte in einer Minute noch einmal.",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("says the session expired on a bare 401", async () => {
|
||||
renderDialog({ onConfirm: vi.fn().mockRejectedValue(new ApiError(401, "Unauthorized")) });
|
||||
|
||||
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "x");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"Sitzung abgelaufen. Bitte die Seite neu laden und erneut anmelden.",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("makes the inputs read-only while the action runs", async () => {
|
||||
renderDialog({ onConfirm: vi.fn(() => new Promise<void>(() => {})) });
|
||||
|
||||
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText("Ihr Passwort")).toHaveAttribute("readonly"));
|
||||
});
|
||||
|
||||
it("cannot be closed with Escape while the action runs, and shows the pending label", async () => {
|
||||
const onConfirm = vi.fn(() => new Promise<void>(() => {}));
|
||||
const { onOpenChange } = renderDialog({ onConfirm, pendingLabel: "Wird eingespielt…" });
|
||||
|
||||
await userEvent.type(screen.getByLabelText("Ihr Passwort"), "geheim");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Verbinden" }));
|
||||
expect(await screen.findByRole("button", { name: "Wird eingespielt…" })).toBeDisabled();
|
||||
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Close/ }));
|
||||
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, RefObject, useId, useRef, useState } from "react";
|
||||
import { stepUpErrorText } from "@/lib/auth/step-up-error";
|
||||
import type { StepUpProof } from "@/lib/telegram/types";
|
||||
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export type { StepUpProof };
|
||||
|
||||
export interface StepUpDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
/** What happens, in one or two short sentences. */
|
||||
description?: string;
|
||||
confirmLabel: string;
|
||||
/** Shown on the button while `onConfirm` runs. */
|
||||
pendingLabel?: string;
|
||||
/** The account has MFA: the current TOTP code is needed as well. */
|
||||
needsMfa: boolean;
|
||||
/** Throws to keep the dialog open; the message is shown to the person. */
|
||||
onConfirm: (proof: StepUpProof) => Promise<void>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Where focus goes when the dialog closes (default: the element that opened it). */
|
||||
finalFocusRef?: RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
const MFA_CODE_PATTERN = /^\d{6}$/;
|
||||
const FALLBACK_ERROR = "Die Bestätigung hat nicht geklappt. Bitte noch einmal versuchen.";
|
||||
|
||||
interface StepUpFormProps {
|
||||
confirmLabel: string;
|
||||
pendingLabel: string;
|
||||
isPending: boolean;
|
||||
setIsPending: (pending: boolean) => void;
|
||||
needsMfa: boolean;
|
||||
onConfirm: (proof: StepUpProof) => Promise<void>;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
// Mounted only while the dialog is open, so the password never outlives it.
|
||||
function StepUpForm({
|
||||
confirmLabel,
|
||||
pendingLabel,
|
||||
isPending,
|
||||
setIsPending,
|
||||
needsMfa,
|
||||
onConfirm,
|
||||
onDone,
|
||||
}: StepUpFormProps) {
|
||||
const passwordId = useId();
|
||||
const mfaId = useId();
|
||||
const [password, setPassword] = useState("");
|
||||
const [mfaCode, setMfaCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
const { isOffline, hintId, describedBy } = useOnlineOnly();
|
||||
|
||||
const proofComplete = password.length > 0 && (!needsMfa || MFA_CODE_PATTERN.test(mfaCode));
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!proofComplete || isPending || isOffline) return;
|
||||
setError(null);
|
||||
setIsPending(true);
|
||||
try {
|
||||
await onConfirm(needsMfa ? { password, mfaCode } : { password });
|
||||
setIsPending(false);
|
||||
onDone();
|
||||
} catch (cause) {
|
||||
setError(stepUpErrorText(cause, FALLBACK_ERROR));
|
||||
setPassword("");
|
||||
setMfaCode("");
|
||||
setIsPending(false);
|
||||
passwordRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-3" onSubmit={handleSubmit} noValidate>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={passwordId}>Ihr Passwort</Label>
|
||||
<Input
|
||||
id={passwordId}
|
||||
ref={passwordRef}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
className="h-11"
|
||||
value={password}
|
||||
readOnly={isPending}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{needsMfa ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={mfaId}>Code aus der Authenticator-App</Label>
|
||||
<Input
|
||||
id={mfaId}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
className="h-11"
|
||||
value={mfaCode}
|
||||
readOnly={isPending}
|
||||
onChange={(event) => setMfaCode(event.target.value.replace(/\D/g, ""))}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{/* Mounted all the time, so a screen reader announces the text when it appears. */}
|
||||
<p role="alert" className="min-h-5 text-sm font-medium text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
<OnlineOnlyHint id={hintId} />
|
||||
<Button
|
||||
type="submit"
|
||||
size="touch"
|
||||
disabled={!proofComplete || isPending || isOffline}
|
||||
aria-describedby={describedBy}
|
||||
>
|
||||
{isPending ? pendingLabel : confirmLabel}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for the current password (and the TOTP code with MFA) before a
|
||||
* sensitive action. The proof goes to `onConfirm` and is forgotten when the
|
||||
* dialog closes.
|
||||
*/
|
||||
export function StepUpDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
pendingLabel = "Wird geprüft…",
|
||||
needsMfa,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
finalFocusRef,
|
||||
}: StepUpDialogProps) {
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
return (
|
||||
// Esc, the backdrop and the close button must not abandon a running action.
|
||||
<Dialog open={open} onOpenChange={(next) => (next || !isPending) && onOpenChange(next)}>
|
||||
<DialogContent finalFocus={finalFocusRef}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
</DialogHeader>
|
||||
<StepUpForm
|
||||
confirmLabel={confirmLabel}
|
||||
pendingLabel={pendingLabel}
|
||||
isPending={isPending}
|
||||
setIsPending={setIsPending}
|
||||
needsMfa={needsMfa}
|
||||
onConfirm={onConfirm}
|
||||
onDone={() => onOpenChange(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,8 @@ interface ConfirmDialogProps {
|
||||
triggerSize?: VariantProps<typeof buttonVariants>["size"];
|
||||
triggerClassName?: string;
|
||||
triggerDisabled?: boolean;
|
||||
/** Id of a hint that explains why the trigger is off (e.g. „Nur mit Verbindung“). */
|
||||
triggerDescribedBy?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel: string;
|
||||
@@ -39,6 +41,7 @@ export function ConfirmDialog({
|
||||
triggerSize = "default",
|
||||
triggerClassName,
|
||||
triggerDisabled,
|
||||
triggerDescribedBy,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
@@ -57,6 +60,7 @@ export function ConfirmDialog({
|
||||
className={cn(triggerClassName)}
|
||||
aria-label={triggerAriaLabel}
|
||||
disabled={triggerDisabled}
|
||||
aria-describedby={triggerDescribedBy}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{triggerLabel}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
|
||||
interface SwitchRowProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** A yes/no setting as a tall row (>= 44 px) with its explanation under the label. */
|
||||
export function SwitchRow({ label, description, checked, onChange, disabled }: SwitchRowProps) {
|
||||
const id = useId();
|
||||
return (
|
||||
<label className="flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border p-3 has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring">
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-labelledby={`${id}-label`}
|
||||
aria-describedby={description ? `${id}-description` : undefined}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="mt-1 size-4 accent-[var(--primary)]"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span id={`${id}-label`} className="font-medium">
|
||||
{label}
|
||||
</span>
|
||||
{description ? (
|
||||
<span id={`${id}-description`} className="text-sm text-muted-foreground">
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -9,19 +9,21 @@ interface ToggleChipProps {
|
||||
onChange: (checked: boolean) => void;
|
||||
/** Ids of a hint or error shown next to the tile. */
|
||||
describedBy?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** A yes/no observation as a large tile; state shown by tick AND colour. */
|
||||
export function ToggleChip({ label, checked, onChange, describedBy }: ToggleChipProps) {
|
||||
export function ToggleChip({ label, checked, onChange, describedBy, disabled }: ToggleChipProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
aria-describedby={describedBy}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
"flex min-h-11 items-center gap-2 rounded-lg border px-3 text-left text-sm motion-safe:transition-colors",
|
||||
"flex min-h-11 items-center gap-2 rounded-lg border px-3 text-left text-sm motion-safe:transition-colors disabled:pointer-events-none disabled:opacity-50",
|
||||
checked
|
||||
? "border-primary bg-primary/10 font-medium"
|
||||
: "border-border bg-background hover:bg-muted",
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface PrefillStatusProps {
|
||||
isError: boolean;
|
||||
isRetrying: boolean;
|
||||
onRetry: () => void;
|
||||
/** Show the own loading line; a page with its own loading line passes `false`. */
|
||||
showLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,10 +19,10 @@ export interface PrefillStatusProps {
|
||||
* until a retry works. Saving stays possible - the server checks anyway. The
|
||||
* region stays mounted so the change is announced.
|
||||
*/
|
||||
export function PrefillStatus({ enabled, isLoading, isError, isRetrying, onRetry }: PrefillStatusProps) {
|
||||
export function PrefillStatus({ enabled, isLoading, isError, isRetrying, onRetry, showLoading = true }: PrefillStatusProps) {
|
||||
return (
|
||||
<div role="status" aria-live="polite">
|
||||
{enabled && isLoading ? (
|
||||
{showLoading && enabled && isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Hinweise werden geladen …</p>
|
||||
) : null}
|
||||
{enabled && isError ? (
|
||||
|
||||
Reference in new issue
Block a user