feat: T4 wave 11 – bottling form with article values and confirmation, Telegram in the app
Backend Image / build-and-push (push) Successful in 4m27s
Frontend Image / build-and-push (push) Successful in 9m34s
Manifests / validate (push) Successful in 19s

- 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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 01:21:08 +02:00
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 ? (