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
@@ -144,6 +144,27 @@ describe("api-client token refresh", () => {
|
||||
expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/things"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["JSON", () => apiFetch("/proof", { method: "POST" }, { refreshOn401: false })],
|
||||
[
|
||||
"form data",
|
||||
() => apiFetchFormData("/proof", new FormData(), { refreshOn401: false }),
|
||||
],
|
||||
])("sends %s once and shows the server's message on 401 with refreshOn401: false", async (_name, call) => {
|
||||
const fetchMock = vi.fn<(url: string) => Promise<Response>>(async () =>
|
||||
jsonResponse(401, { message: "Das Passwort ist falsch." }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(call()).rejects.toMatchObject({
|
||||
status: 401,
|
||||
message: "Das Passwort ist falsch.",
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).not.toContain("/auth/refresh");
|
||||
});
|
||||
|
||||
it("takes the token as it is without a handler", async () => {
|
||||
stubServer({ accessToken: "fresh" });
|
||||
|
||||
|
||||
@@ -83,6 +83,18 @@ export async function apiFetchBlob(path: string, fallbackFilename: string): Prom
|
||||
export interface FormDataOptions {
|
||||
headers?: HeadersInit;
|
||||
timeoutMs?: number;
|
||||
/** See ApiFetchOptions.refreshOn401. */
|
||||
refreshOn401?: boolean;
|
||||
}
|
||||
|
||||
export interface ApiFetchOptions {
|
||||
/**
|
||||
* Default true: a 401 refreshes the token and repeats the request. Step-up
|
||||
* calls (password / TOTP proof) set false: their 401 means "proof wrong",
|
||||
* a repeat would count twice towards the lockout and a failed refresh
|
||||
* could log the person out. The server's message is shown unchanged.
|
||||
*/
|
||||
refreshOn401?: boolean;
|
||||
}
|
||||
|
||||
async function rawFetchFormData(
|
||||
@@ -111,7 +123,7 @@ export async function apiFetchFormData<T>(
|
||||
): Promise<T> {
|
||||
let response = await rawFetchFormData(path, formData, options);
|
||||
|
||||
if (response.status === 401) {
|
||||
if (response.status === 401 && options.refreshOn401 !== false) {
|
||||
const refreshed = await refreshRequest();
|
||||
if (refreshed && (await acceptRefreshedToken(refreshed.accessToken))) {
|
||||
response = await rawFetchFormData(path, formData, options);
|
||||
@@ -125,10 +137,14 @@ export async function apiFetchFormData<T>(
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
options: ApiFetchOptions = {},
|
||||
): Promise<T> {
|
||||
let response = await rawFetch(path, init);
|
||||
|
||||
if (response.status === 401) {
|
||||
if (response.status === 401 && options.refreshOn401 !== false) {
|
||||
const refreshed = await refreshRequest();
|
||||
if (refreshed && (await acceptRefreshedToken(refreshed.accessToken))) {
|
||||
response = await rawFetch(path, init);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { stepUpErrorText } from "./step-up-error";
|
||||
|
||||
describe("stepUpErrorText", () => {
|
||||
it("maps a generic 401 to the session text", () => {
|
||||
for (const message of ["Unauthorized", "", "Request failed"]) {
|
||||
expect(stepUpErrorText(new ApiError(401, message), "x")).toBe(
|
||||
"Sitzung abgelaufen. Bitte die Seite neu laden und erneut anmelden.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes a German reauth message through unchanged", () => {
|
||||
expect(stepUpErrorText(new ApiError(401, "Das Passwort ist falsch."), "x")).toBe(
|
||||
"Das Passwort ist falsch.",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps 429 and falls back for other errors", () => {
|
||||
expect(stepUpErrorText(new ApiError(429, "ThrottlerException"), "x")).toBe(
|
||||
"Zu viele Versuche. Bitte in einer Minute noch einmal.",
|
||||
);
|
||||
expect(stepUpErrorText(new Error("boom"), "Fallback")).toBe("Fallback");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { saveErrorText } from "@/lib/honey-batches/errors";
|
||||
|
||||
export const SESSION_EXPIRED_TEXT =
|
||||
"Sitzung abgelaufen. Bitte die Seite neu laden und erneut anmelden.";
|
||||
export const TOO_MANY_ATTEMPTS_TEXT = "Zu viele Versuche. Bitte in einer Minute noch einmal.";
|
||||
|
||||
/** Step-up calls skip the silent refresh, so a 401 can also mean "session over". */
|
||||
function isGenericUnauthorized(error: ApiError): boolean {
|
||||
const message = error.message.trim();
|
||||
return message === "" || message === "Unauthorized" || message === "Request failed";
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE place that words a failed step-up call (password / TOTP proof):
|
||||
* 429 -> too many attempts, a bare 401 -> session expired, a German server
|
||||
* message (wrong password or code) unchanged, anything else as saveErrorText.
|
||||
* The api client does not know the token's expiry, so there is no proactive
|
||||
* refresh before the call.
|
||||
*/
|
||||
export function stepUpErrorText(error: unknown, fallback: string): string {
|
||||
if (error instanceof ApiError) {
|
||||
if (error.status === 429) return TOO_MANY_ATTEMPTS_TEXT;
|
||||
if (error.status === 401 && isGenericUnauthorized(error)) return SESSION_EXPIRED_TEXT;
|
||||
}
|
||||
return saveErrorText(error, fallback);
|
||||
}
|
||||
Reference in new issue
Block a user