Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/guides/translations.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ no locale URL prefix. The locale is resolved on the client.
catalog (for a non-default locale, the brief English flash before that is
inherent to client-side locale resolution). A chunk that fails to load
logs and falls back to its English counterpart.
3. The provider records the locale it actually renders in
3. The provider records the locale it actually renders, and its catalog, in
`frontend/lib/i18n/active-locale.ts`. The cookie stores the *preference*;
the active locale tracks what is on screen, and the two diverge when the
catalog load fails outright and the UI stays on English.
Expand All @@ -261,6 +261,12 @@ const t = useTranslations("home");
<h1>{t.rich("title", { brand: (chunks) => <span className="text-primary-500">{chunks}</span> })}</h1>
```

Code outside React, such as the API client's network error, translates with next-intl's
`createTranslator` over the active catalog (`getActiveLocale()` and `getActiveMessages()`).
The catalog drift guard below only finds keys used through `useTranslations` or
`getTranslations`, so declare each such key with an `// i18n-check t("<namespace>.<key>")`
comment next to the call.

Messages use ICU syntax for interpolation and plurals
(`"lastDays": "last {days} days"`). Keys are written by hand into
`frontend/messages/en.json` first; `es.json` and `fr.json` must carry the same
Expand Down
3 changes: 2 additions & 1 deletion frontend/app/LocaleProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useEffect, useState } from "react";
import en from "@/messages/en.json";
// Registers the plugins whose catalogs loadMessages merges in.
import "@/app/plugins";
import { setActiveLocale } from "@/lib/i18n/active-locale";
import { setActiveLocale, setActiveMessages } from "@/lib/i18n/active-locale";
import { defaultLocale, readLocaleCookie, type Locale } from "@/lib/i18n/config";
import { loadMessages, type LoadedMessages } from "@/lib/i18n/messages";

Expand All @@ -31,6 +31,7 @@ export function LocaleProvider({ children }: { children: React.ReactNode }) {
if (cancelled) return;
document.documentElement.lang = locale;
setActiveLocale(locale);
setActiveMessages(messages);
setState({ locale, messages });
})
.catch((error: unknown) => {
Expand Down
84 changes: 23 additions & 61 deletions frontend/app/dashboard/admin/whitelist/WhitelistPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,7 @@ import { Button } from "@/core/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Alert } from "@/components/ui/Alert";
import { Spinner } from "@/core/components/ui/Spinner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/Dialog";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";

interface WhitelistState {
entries: WhitelistEntry[];
Expand All @@ -33,7 +26,6 @@ interface WhitelistState {
addError: string;
adding: boolean;
deleteTarget: WhitelistEntry | null;
deleting: boolean;
}

type WhitelistAction =
Expand All @@ -43,8 +35,7 @@ type WhitelistAction =
| { type: "SET_NEW_VALUE"; value: string }
| { type: "SET_ADD_ERROR"; error: string }
| { type: "SET_ADDING"; adding: boolean }
| { type: "SET_DELETE_TARGET"; target: WhitelistEntry | null }
| { type: "SET_DELETING"; deleting: boolean };
| { type: "SET_DELETE_TARGET"; target: WhitelistEntry | null };

function reducer(state: WhitelistState, action: WhitelistAction): WhitelistState {
switch (action.type) {
Expand All @@ -62,8 +53,6 @@ function reducer(state: WhitelistState, action: WhitelistAction): WhitelistState
return { ...state, adding: action.adding };
case "SET_DELETE_TARGET":
return { ...state, deleteTarget: action.target };
case "SET_DELETING":
return { ...state, deleting: action.deleting };
}
}

Expand All @@ -75,7 +64,6 @@ const initialState: WhitelistState = {
addError: "",
adding: false,
deleteTarget: null,
deleting: false,
};

export default function WhitelistPage() {
Expand Down Expand Up @@ -129,22 +117,10 @@ export default function WhitelistPage() {
};

const handleDelete = async () => {
if (!token || !state.deleteTarget) return;

dispatch({ type: "SET_DELETING", deleting: true });
try {
await removeWhitelistEntry(token, state.deleteTarget.id);
dispatch({ type: "SET_DELETE_TARGET", target: null });
await fetchEntries();
} catch (err) {
dispatch({
type: "SET_ERROR",
error: err instanceof ApiRequestError ? err.message : "Failed to remove entry",
});
dispatch({ type: "SET_DELETE_TARGET", target: null });
} finally {
dispatch({ type: "SET_DELETING", deleting: false });
}
if (!token || !state.deleteTarget) throw new Error("not signed in");
await removeWhitelistEntry(token, state.deleteTarget.id);
dispatch({ type: "SET_DELETE_TARGET", target: null });
await fetchEntries();
};

if (access === "checking") {
Expand Down Expand Up @@ -274,43 +250,29 @@ export default function WhitelistPage() {
)}
</div>

<Dialog
open={!!state.deleteTarget}
onOpenChange={() => dispatch({ type: "SET_DELETE_TARGET", target: null })}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Remove whitelist entry</DialogTitle>
<DialogDescription>
{state.deleteTarget && (
<ConfirmDialog
title="Remove whitelist entry"
description={
<>
Are you sure you want to remove{" "}
<span className="font-mono font-medium text-foreground">
{state.deleteTarget?.value}
{state.deleteTarget.value}
</span>
?
{state.deleteTarget?.entry_type === "domain"
{state.deleteTarget.entry_type === "domain"
? " Users from this domain will no longer be able to register."
: " This email will no longer be able to register."}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => dispatch({ type: "SET_DELETE_TARGET", target: null })}
disabled={state.deleting}
>
Cancel
</Button>
<Button
variant="error"
onClick={handleDelete}
loading={state.deleting}
spinnerLabel="Removing"
>
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
}
confirmLabel="Remove"
pendingLabel="Removing"
cancelLabel="Cancel"
errorMessage="Failed to remove the entry."
Comment thread
abdulrafey1 marked this conversation as resolved.
onConfirm={handleDelete}
onClose={() => dispatch({ type: "SET_DELETE_TARGET", target: null })}
/>
)}
</div>
);
}
88 changes: 88 additions & 0 deletions frontend/components/ui/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"use client";

import { useState } from "react";

import { Alert } from "@/components/ui/Alert";
import { Button } from "@/core/components/ui/Button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/Dialog";
import { ApiRequestError } from "@/lib/api";

/**
* A dialog that asks before running an action, such as a delete.
*
* While `onConfirm` runs, both buttons are disabled and the dialog cannot be dismissed. If it
* rejects, the error is logged and the dialog stays open showing the message of an
* `ApiRequestError`, or `errorMessage` for any other error. On success the caller closes the
* dialog, usually by unmounting it.
*/
export function ConfirmDialog({
title,
description,
confirmLabel,
pendingLabel,
cancelLabel,
errorMessage,
onConfirm,
onClose,
}: {
title: string;
description: React.ReactNode;
confirmLabel: string;
pendingLabel: string;
cancelLabel: string;
errorMessage: string;
onConfirm: () => Promise<void>;
onClose: () => void;
}): React.JSX.Element {
const [loading, setLoading] = useState(false);
const [shownError, setShownError] = useState<string | null>(null);

async function handleConfirm() {
setLoading(true);
setShownError(null);
try {
await onConfirm();
} catch (error) {
console.error("ConfirmDialog: action failed", error);
setShownError(error instanceof ApiRequestError ? error.message : errorMessage);
} finally {
setLoading(false);
}
}

return (
<Dialog open onOpenChange={(open) => !open && !loading && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>

{shownError && <Alert severity="error">{shownError}</Alert>}

<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={onClose} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant="error"
size="sm"
loading={loading}
spinnerLabel={pendingLabel}
disabled={loading}
onClick={handleConfirm}
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
17 changes: 13 additions & 4 deletions frontend/lib/api/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { createTranslator } from "next-intl";

import { getActiveLocale, getActiveMessages } from "@/lib/i18n/active-locale";

export interface ValidationError {
loc: (string | number)[];
msg: string;
Expand Down Expand Up @@ -85,9 +89,14 @@ export class ApiRequestError extends Error {
export function rethrowOrWrapConnectionError(error: unknown): never {
if (error instanceof ApiRequestError) throw error;
if (error instanceof DOMException && error.name === "AbortError") throw error;
const message = error instanceof Error ? error.message : "Unknown error";
throw new ApiRequestError({
message: `Unable to connect to server: ${message}`,
fieldErrors: {},
const detail = error instanceof Error ? error.message : "Unknown error";
const t = createTranslator({
locale: getActiveLocale(),
messages: getActiveMessages(),
namespace: "api",
});
// i18n-check only finds keys used through React's translation hooks, so this key is declared
// with the marker below.
// i18n-check t("api.connectionFailed")
throw new ApiRequestError({ message: t("connectionFailed", { detail }), fieldErrors: {} });
}
15 changes: 14 additions & 1 deletion frontend/lib/i18n/active-locale.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import en from "@/messages/en.json";

import { defaultLocale, type Locale } from "./config";
import type { LoadedMessages } from "./messages";

// The locale the UI is rendering right now, maintained by LocaleProvider. The
// NEXT_LOCALE cookie stores the *preference*; this tracks what is on screen, and the
// two diverge when the catalog load fails outright and the UI stays on the default
// locale (a single failed chunk instead falls back to its English counterpart). API calls read this value so Accept-Language always matches what the user
// sees rather than what the cookie asks for.
// sees rather than what the cookie asks for. Code outside React translates with the
// catalog recorded alongside it.
let activeLocale: Locale = defaultLocale;
let activeMessages: LoadedMessages = en;

export function getActiveLocale(): Locale {
return activeLocale;
Expand All @@ -14,3 +19,11 @@ export function getActiveLocale(): Locale {
export function setActiveLocale(locale: Locale): void {
activeLocale = locale;
}

export function getActiveMessages(): LoadedMessages {
return activeMessages;
}

export function setActiveMessages(messages: LoadedMessages): void {
activeMessages = messages;
}
9 changes: 3 additions & 6 deletions frontend/lib/slack/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ function bearer(token: string): { Authorization: string } {
}

// This module's public contract is plain Error objects with action-prefixed
// messages; network failures propagate untouched.
// messages, except disconnectSlack, which rejects with the ApiRequestError itself;
// network failures propagate untouched.
function toError(prefix: string, error: unknown): never {
if (error instanceof ApiRequestError) throw new Error(`${prefix}: ${error.message}`);
throw error;
Expand All @@ -37,11 +38,7 @@ export async function getAuthorizationUrl(token: string): Promise<string> {
}

export async function disconnectSlack(token: string): Promise<void> {
try {
await api.DELETE("/api/v1/slack/oauth/disconnect", { headers: bearer(token) });
} catch (error) {
toError("Failed to disconnect Slack", error);
}
await api.DELETE("/api/v1/slack/oauth/disconnect", { headers: bearer(token) });
}

export async function fetchLogs(token: string, opts: FetchLogsOptions): Promise<LogsResponse> {
Expand Down
3 changes: 3 additions & 0 deletions frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -230,5 +230,8 @@
"requestNew": "Request a new link",
"error": "We couldn't reset your password. Please try again."
}
},
"api": {
"connectionFailed": "Unable to connect to server: {detail}"
}
}
3 changes: 3 additions & 0 deletions frontend/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -230,5 +230,8 @@
"requestNew": "Solicitar un nuevo enlace",
"error": "No pudimos restablecer tu contrase帽a. Int茅ntalo de nuevo."
}
},
"api": {
"connectionFailed": "No se pudo conectar con el servidor: {detail}"
}
}
3 changes: 3 additions & 0 deletions frontend/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -230,5 +230,8 @@
"requestNew": "Demander un nouveau lien",
"error": "Impossible de r茅initialiser votre mot de passe. Veuillez r茅essayer."
}
},
"api": {
"connectionFailed": "Impossible de se connecter au serveur : {detail}"
}
}
Loading
Loading