From ab2c5cb2aad154487404eccaf702bc76f20f9704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:05:35 +0200 Subject: [PATCH] fix(settings): show a read failure on the API-token list, not an empty state Same fall-through as the share-link list on another security surface: the token query had no error or loading branch, so a failed read rendered "no active tokens". Gate the list on the query state with QueryErrorCard and retry, a skeleton while loading. Test watched fail first against the old empty-state fall-through. --- .../api-section-error-state.test.tsx | 61 ++ src/components/settings/api-section.tsx | 563 +++++++++--------- 2 files changed, 353 insertions(+), 271 deletions(-) create mode 100644 src/components/settings/__tests__/api-section-error-state.test.tsx diff --git a/src/components/settings/__tests__/api-section-error-state.test.tsx b/src/components/settings/__tests__/api-section-error-state.test.tsx new file mode 100644 index 000000000..6b3f01754 --- /dev/null +++ b/src/components/settings/__tests__/api-section-error-state.test.tsx @@ -0,0 +1,61 @@ +/** + * A read failure on the API-token list must not read as "no active tokens". + * `useQuery` is forced into its error state; the card has to render the + * `query-error-card` slot with retry and must never fall through to the empty + * state — the same §6 fall-through the share-link surface had, on another + * security surface. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/api", + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: undefined, + isLoading: false, + isError: true, + refetch: vi.fn(), + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ + mutate: vi.fn(), + isPending: false, + isError: false, + error: null, + }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", username: "testuser", role: "USER" }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { ApiSection } from "../api-section"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("ApiSection — a token-read failure is honest, not empty", () => { + it("renders the query-error card with retry and never the empty state", () => { + const html = render(); + expect(html).toContain('data-slot="query-error-card"'); + expect(html).toContain('data-slot="query-error-retry"'); + expect(html).not.toContain('data-testid="settings-api-tokens-active-empty"'); + expect(html).not.toContain("No active tokens"); + }); +}); diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index d065378c4..af2565527 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -19,6 +19,8 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { Skeleton } from "@/components/ui/skeleton"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useAuth } from "@/hooks/use-auth"; @@ -150,7 +152,12 @@ function ApiTokensCard() { const queryClient = useQueryClient(); const [showRevokedTokens, setShowRevokedTokens] = useState(false); - const { data: tokens } = useQuery({ + const { + data: tokens, + isError, + isLoading, + refetch, + } = useQuery({ queryKey: queryKeys.tokens(), queryFn: async () => { return apiGet("/api/tokens"); @@ -215,87 +222,196 @@ function ApiTokensCard() { medication. This card lists and revokes. */}

{t("settings.tokenMintMovedDescription")}

-
-

- {t("settings.activeTokensTitle")} -

- {/* Desktop table — verbatim layout for md+, hidden on phones. */} -
- - - - - - - - - - - - - {activeTokens.length === 0 && ( - - - - )} - {activeTokens.map((tok, index) => { - const isExpired = - tok.expiresAt && new Date(tok.expiresAt) < new Date(); - return ( - - - - - - - - - ); - })} - -
- {t("settings.tokenTableName")} - - {t("settings.tokenTablePermissions")} - - {t("settings.tokenTableStatus")} - - {t("settings.tokenTableCreated")} - - {t("settings.tokenTableLastUsed")} - - {t("settings.tokenTableActions")} -
- {t("settings.noActiveTokens")} -
{tok.name} - {tok.permissions.join(", ")} - - {isExpired ? ( - - {t("settings.tokenExpired")} - - ) : ( - - {t("settings.tokenActive")} - - )} - - {formatDate(tok.createdAt)} - - {tok.lastUsedAt - ? formatDateTime(tok.lastUsedAt) - : t("settings.tokenNeverUsed")} - + {isError ? ( + // A read failure must not read as "no active tokens" on this + // security surface — the §6 fall-through the token list must avoid. + refetch()} /> + ) : isLoading ? ( +
+ +
+ ) : ( + <> +
+

+ {t("settings.activeTokensTitle")} +

+ {/* Desktop table — verbatim layout for md+, hidden on phones. */} +
+ + + + + + + + + + + + + {activeTokens.length === 0 && ( + + + + )} + {activeTokens.map((tok, index) => { + const isExpired = + tok.expiresAt && new Date(tok.expiresAt) < new Date(); + return ( + + + + + + + + + ); + })} + +
+ {t("settings.tokenTableName")} + + {t("settings.tokenTablePermissions")} + + {t("settings.tokenTableStatus")} + + {t("settings.tokenTableCreated")} + + {t("settings.tokenTableLastUsed")} + + {t("settings.tokenTableActions")} +
+ {t("settings.noActiveTokens")} +
{tok.name} + {tok.permissions.join(", ")} + + {isExpired ? ( + + {t("settings.tokenExpired")} + + ) : ( + + {t("settings.tokenActive")} + + )} + + {formatDate(tok.createdAt)} + + {tok.lastUsedAt + ? formatDateTime(tok.lastUsedAt) + : t("settings.tokenNeverUsed")} + + + + + + + + + {t("settings.tokenRevoke")} + + + {t("settings.tokenRevokeDescription")} + + + + + {t("common.cancel")} + + handleRevoke(tok.id)} + > + {t("settings.tokenRevoked")} + + + + +
+
+ + {/* Mobile card list — each row stacks its columns vertically + with explicit labels so the data is readable inside a + narrow viewport. The revoke action stays full-width to + clear the 44pt tap-target floor. */} + {activeTokens.length === 0 ? ( + + ) : ( +
    + {activeTokens.map((tok) => { + const isExpired = + tok.expiresAt && new Date(tok.expiresAt) < new Date(); + return ( +
  • +
    +

    + {tok.name} +

    + {isExpired ? ( + + {t("settings.tokenExpired")} + + ) : ( + + {t("settings.tokenActive")} + + )} +
    +

    + + {t("settings.tokenTablePermissions")}: + {" "} + {tok.permissions.join(", ")} +

    +

    + + {t("settings.tokenTableCreated")}: + {" "} + {formatDate(tok.createdAt)} +

    +

    + + {t("settings.tokenTableLastUsed")}: + {" "} + {tok.lastUsedAt + ? formatDateTime(tok.lastUsedAt) + : t("settings.tokenNeverUsed")} +

    @@ -320,212 +436,117 @@ function ApiTokensCard() { -
-
+ + ); + })} + + )} +
- {/* Mobile card list — each row stacks its columns vertically - with explicit labels so the data is readable inside a - narrow viewport. The revoke action stays full-width to - clear the 44pt tap-target floor. */} - {activeTokens.length === 0 ? ( - - ) : ( -
    - {activeTokens.map((tok) => { - const isExpired = - tok.expiresAt && new Date(tok.expiresAt) < new Date(); - return ( -
  • -
    -

    - {tok.name} -

    - {isExpired ? ( - - {t("settings.tokenExpired")} - - ) : ( - - {t("settings.tokenActive")} - - )} + {revokedTokens.length > 0 && ( +
    + + {showRevokedTokens && ( + <> + {/* Desktop table — verbatim layout for md+. */} +
    + + + + + + + + + + + {revokedTokens.map((tok, index) => ( + + + + + + + ))} + +
    + {t("settings.tokenTableName")} + + {t("settings.tokenTablePermissions")} + + {t("settings.tokenTableCreated")} + + {t("settings.tokenTableLastUsed")} +
    + {tok.name} + + {tok.permissions.join(", ")} + + {formatDate(tok.createdAt)} + + {tok.lastUsedAt + ? formatDateTime(tok.lastUsedAt) + : t("settings.tokenNeverUsed")} +
    -

    - - {t("settings.tokenTablePermissions")}: - {" "} - {tok.permissions.join(", ")} -

    -

    - - {t("settings.tokenTableCreated")}: - {" "} - {formatDate(tok.createdAt)} -

    -

    - - {t("settings.tokenTableLastUsed")}: - {" "} - {tok.lastUsedAt - ? formatDateTime(tok.lastUsedAt) - : t("settings.tokenNeverUsed")} -

    - - - - - - - - {t("settings.tokenRevoke")} - - - {t("settings.tokenRevokeDescription")} - - - - - {t("common.cancel")} - - handleRevoke(tok.id)} - > - {t("settings.tokenRevoked")} - - - - -
  • - ); - })} -
- )} - - {revokedTokens.length > 0 && ( -
- - {showRevokedTokens && ( - <> - {/* Desktop table — verbatim layout for md+. */} -
- - - - - - - - - - - {revokedTokens.map((tok, index) => ( - + {revokedTokens.map((tok) => ( +
  • -
  • - - - - +

    + ))} - -
    - {t("settings.tokenTableName")} - - {t("settings.tokenTablePermissions")} - - {t("settings.tokenTableCreated")} - - {t("settings.tokenTableLastUsed")} -
    {tok.name} +

    + {tok.name} +

    +

    + + {t("settings.tokenTablePermissions")}: + {" "} {tok.permissions.join(", ")} -

    +

    +

    + + {t("settings.tokenTableCreated")}: + {" "} {formatDate(tok.createdAt)} -

    +

    +

    + + {t("settings.tokenTableLastUsed")}: + {" "} {tok.lastUsedAt ? formatDateTime(tok.lastUsedAt) : t("settings.tokenNeverUsed")} -

    -
    - - {/* Mobile card list — stacked meta layout, no - horizontal scroll. Revoked tokens are read-only so - no action footer is needed. */} -
      - {revokedTokens.map((tok) => ( -
    • -

      - {tok.name} -

      -

      - - {t("settings.tokenTablePermissions")}: - {" "} - {tok.permissions.join(", ")} -

      -

      - - {t("settings.tokenTableCreated")}: - {" "} - {formatDate(tok.createdAt)} -

      -

      - - {t("settings.tokenTableLastUsed")}: - {" "} - {tok.lastUsedAt - ? formatDateTime(tok.lastUsedAt) - : t("settings.tokenNeverUsed")} -

      -
    • - ))} -
    - + + + )} +
    )} - + )}