{searchActive
- ? "Adjust filters or invite a new user."
- : "Register the first member to seed this tenant."}
+ ? t("empty.withFilters")
+ : t("empty.noData")}
- URLs to your hosted brand assets. Upload via the Files module first,
- then paste the resulting public URL here.
+ {t("branding.assets.description")}
onChange({ logoUrl: v || null, deleteLogo: v.length === 0 })
@@ -319,7 +323,7 @@ function BrandAssetsEditor({
/>
onChange({ logoDarkUrl: v || null, deleteLogoDark: v.length === 0 })
@@ -327,7 +331,7 @@ function BrandAssetsEditor({
/>
onChange({ faviconUrl: v || null, deleteFavicon: v.length === 0 })
@@ -384,6 +388,7 @@ function AssetField({
// ─────────────────────────────────────────────────────────────────────────
function ThemePreview({ palette, label }: { palette: PaletteDto; label: string }) {
+ const { t } = useTranslation("tenants");
return (
- A short paragraph rendered with the chosen body color over the chosen
- surface, on the chosen page background. Action buttons use the primary token.
+ {t("branding.preview.paragraph")}
@@ -478,10 +482,10 @@ function ThemePreview({ palette, label }: { palette: PaletteDto; label: string }
);
}
-function apiErr(err: unknown): string {
+function apiErr(err: unknown, fallback: string): string {
if (err instanceof ApiRequestError) {
return err.problem?.detail ?? err.problem?.title ?? err.message;
}
if (err instanceof Error) return err.message;
- return "Unknown error";
+ return fallback;
}
diff --git a/clients/admin/src/components/webhooks/create-webhook-dialog.tsx b/clients/admin/src/components/webhooks/create-webhook-dialog.tsx
index 3d620d0df0..1cdc10e71d 100644
--- a/clients/admin/src/components/webhooks/create-webhook-dialog.tsx
+++ b/clients/admin/src/components/webhooks/create-webhook-dialog.tsx
@@ -1,4 +1,5 @@
import { useState } from "react";
+import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -24,13 +25,14 @@ import { Field } from "@/components/list";
import { ApiRequestError } from "@/lib/api-client";
import { cn } from "@/lib/cn";
+// Validation messages are i18n keys, resolved to text at render via t().
const schema = z.object({
url: z
.string()
.trim()
- .url("Must be a valid http(s) URL.")
+ .url("create.validation.url")
.refine((u) => u.startsWith("https://") || u.startsWith("http://"), {
- message: "Use http:// or https://",
+ message: "create.validation.scheme",
}),
secret: z
.string()
@@ -50,6 +52,7 @@ export function CreateWebhookDialog({
onOpenChange: (open: boolean) => void;
onCreated?: (id: string) => void;
}) {
+ const { t } = useTranslation("webhooks");
const [events, setEvents] = useState([]);
const [draftEvent, setDraftEvent] = useState("");
@@ -77,8 +80,8 @@ export function CreateWebhookDialog({
events,
}),
onSuccess: (id) => {
- toast.success("Subscription created", {
- description: "Use the Test button on the row to verify the endpoint accepts events.",
+ toast.success(t("create.toast.created"), {
+ description: t("create.toast.createdDesc"),
});
onCreated?.(id);
reset_();
@@ -89,14 +92,14 @@ export function CreateWebhookDialog({
err instanceof ApiRequestError
? err.problem?.detail ?? err.problem?.title ?? err.message
: (err as Error).message;
- toast.error("Create failed", { description: detail });
+ toast.error(t("create.toast.createFailed"), { description: detail });
},
});
const onSubmit = handleSubmit((values) => {
if (events.length === 0) {
- toast.warning("Pick at least one event", {
- description: "Subscriptions with no events would never fire.",
+ toast.warning(t("create.toast.pickEvent"), {
+ description: t("create.toast.pickEventDesc"),
});
return;
}
@@ -135,18 +138,16 @@ export function CreateWebhookDialog({
>
- New webhook subscription
+ {t("create.title")}
- Your endpoint receives a JSON payload with the event details. We sign each request
- with HMAC-SHA256 in the X-FSH-Signature header
- using the secret below — store it on your side and verify before trusting the body.
+ {t("create.description", { header: "X-FSH-Signature" })}
- This tenant wasn't created through the provisioning pipeline, so there's no run
- history to show. Tenants created via the console report their seed/migrate steps here.
+ {t("detail.provisioning.notTrackedBody")}
) : steps.length === 0 ? (
- No provisioning runs recorded.
+ {t("detail.provisioning.noRuns")}
) : (
@@ -548,6 +544,7 @@ function StepRow({
index: number;
isLast: boolean;
}) {
+ const { t } = useTranslation("tenants");
const isCompleted = step.status === "Completed";
const isFailed = step.status === "Failed";
const isRunning = step.status === "Running";
@@ -589,7 +586,7 @@ function StepRow({
step.startedUtc && step.completedUtc
? formatDuration(step.startedUtc, step.completedUtc)
: step.startedUtc
- ? "in flight"
+ ? t("detail.provisioning.inFlight")
: null;
return (
@@ -659,12 +656,6 @@ function StepRow({
// ─── helpers ────────────────────────────────────────────────────────────────
-function formatDate(value: string | undefined): string {
- if (!value) return "—";
- const d = new Date(value);
- return Number.isNaN(d.getTime()) ? value : d.toLocaleDateString();
-}
-
function expiryVariant(state: string): React.ComponentProps["variant"] {
return state === "Expired" ? "danger" : state === "InGrace" ? "warning" : "outline";
}
diff --git a/clients/admin/src/pages/tenants/list.tsx b/clients/admin/src/pages/tenants/list.tsx
index f7d80fc87d..a571132eea 100644
--- a/clients/admin/src/pages/tenants/list.tsx
+++ b/clients/admin/src/pages/tenants/list.tsx
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { Building2, ChevronLeft, ChevronRight, Plus } from "lucide-react";
@@ -8,6 +9,7 @@ import { Monogram } from "@/components/monogram";
import { EntityPageHeader, ErrorBand } from "@/components/list";
import { ApiRequestError } from "@/lib/api-client";
import { cn } from "@/lib/cn";
+import { formatDate } from "@/lib/format";
import { CreateTenantDialog } from "@/components/tenants/create-tenant-dialog";
import { useAuth } from "@/auth/use-auth";
import { MultitenancyPermissions } from "@/lib/permissions";
@@ -17,12 +19,8 @@ const PAGE_SIZE = 12;
// Desktop grid template — shared by header + rows.
const DESKTOP_COLS = "grid-cols-[1fr_140px_24px] lg:grid-cols-[1.6fr_1.4fr_140px_24px]";
-function formatDate(value: string): string {
- const date = new Date(value);
- return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
-}
-
export function TenantsListPage() {
+ const { t } = useTranslation("tenants");
const [pageNumber, setPageNumber] = useState(1);
const [createOpen, setCreateOpen] = useState(false);
const navigate = useNavigate();
@@ -43,22 +41,22 @@ export function TenantsListPage() {
const pageBadge = useMemo(() => {
if (!data) return "—";
const p = String(data.pageNumber).padStart(2, "0");
- const t = String(Math.max(data.totalPages, 1)).padStart(2, "0");
- return `Page ${p} of ${t}`;
- }, [data]);
+ const total = String(Math.max(data.totalPages, 1)).padStart(2, "0");
+ return t("list.page", { page: p, total });
+ }, [data, t]);
return (