Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a10d397
feat(admin): localize the operator app with react-i18next
marcelo-maciel Aug 17, 2026
71dd6fb
feat(admin): emit defaultLanguage in the Docker and Terraform runtime…
marcelo-maciel Sep 16, 2026
764ff1b
build(deps): bump Testcontainers to 4.14.0 and SourceLink past their …
marcelo-maciel Sep 14, 2026
c6a72df
fix(infra): pull MinIO from quay.io on a pinned tag, not Docker Hub
marcelo-maciel Sep 14, 2026
d837c34
fix(admin): make the token refresh single-flight, not per-caller
marcelo-maciel Sep 17, 2026
e188d61
fix(admin): keep the session when a speculative refresh fails
marcelo-maciel Sep 18, 2026
2980007
test(admin): gate interpolation parity, not just keys
marcelo-maciel Sep 18, 2026
e1b525d
fix(admin): stop rendering raw catalog keys for backend status values
marcelo-maciel Sep 18, 2026
45a7796
fix(infra): pull minio/mc from quay.io too, not just minio/minio
marcelo-maciel Sep 18, 2026
f51bcab
build(deps): drop the dead SSH.NET pin
marcelo-maciel Sep 18, 2026
acca242
feat(admin): localize the permission matrix, and stop dates following…
marcelo-maciel Sep 18, 2026
67e19c8
fix(i18n): stop the missing-key handler from eating every defaultValue
marcelo-maciel Sep 18, 2026
429c9a8
fix(admin): number formatting, plurals and the upload errors review f…
marcelo-maciel Sep 18, 2026
c37fc59
fix(admin): let a lost upload key fall through to the segment fallback
marcelo-maciel Sep 18, 2026
43842d1
fix(i18n): namespace the language key, localize the last upload path,…
marcelo-maciel Sep 18, 2026
328dbb4
test(i18n): pin the phone number the language switch echoes back
marcelo-maciel Sep 18, 2026
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
3 changes: 2 additions & 1 deletion clients/admin/docker/config.json.template
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"apiBase": "${FSH_API_URL}",
"defaultTenant": "${FSH_DEFAULT_TENANT}",
"dashboardUrl": "${FSH_DASHBOARD_URL}"
"dashboardUrl": "${FSH_DASHBOARD_URL}",
"defaultLanguage": "${FSH_DEFAULT_LANGUAGE}"
}
6 changes: 4 additions & 2 deletions clients/admin/docker/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ set -e
: "${FSH_API_URL:?FSH_API_URL is required (e.g. https://api.example.com)}"
: "${FSH_DASHBOARD_URL:?FSH_DASHBOARD_URL is required (e.g. https://app.example.com)}"

# Defaults for non-required values.
# Defaults for non-required values. The language default matches the one the bundle falls back to,
# so an unset variable and an absent config.json land on the same UI language.
: "${FSH_DEFAULT_TENANT:=root}"
: "${FSH_DEFAULT_LANGUAGE:=en-US}"

export FSH_API_URL FSH_DASHBOARD_URL FSH_DEFAULT_TENANT
export FSH_API_URL FSH_DASHBOARD_URL FSH_DEFAULT_TENANT FSH_DEFAULT_LANGUAGE

# Render the runtime config from the template, writing into nginx's web root.
envsubst < /usr/share/nginx/html/config.json.template > /usr/share/nginx/html/config.json
Expand Down
105 changes: 104 additions & 1 deletion clients/admin/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions clients/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@
"@types/qrcode": "^1.5.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^26.3.6",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.475.0",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-i18next": "^17.0.10",
"react-router-dom": "^7.1.5",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.1",
Expand Down
5 changes: 5 additions & 0 deletions clients/admin/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export default defineConfig({
actionTimeout: 10_000,
navigationTimeout: 15_000,
},
// Assertions get the same budget as actions. Left at the 5s default they were
// the tightest deadline in the suite — every test ends in a toBeVisible, and
// under CPU contention the first paint of a lazy route lands past 5s while
// staying well inside the action and navigation budgets.
expect: { timeout: 10_000 },
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: "npm run dev",
Expand Down
3 changes: 2 additions & 1 deletion clients/admin/public/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"defaultTenant": "root",
"dashboardUrl": "http://localhost:5174",
"inactivityIdleMs": 600000,
"inactivityWarningMs": 60000
"inactivityWarningMs": 60000,
"defaultLanguage": "en-US"
}
4 changes: 3 additions & 1 deletion clients/admin/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Suspense } from "react";
import { RouterProvider } from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "sonner";
import { useTranslation } from "react-i18next";
import { AlertCircle, AlertTriangle, CheckCircle2, Info, Loader2 } from "lucide-react";
import { queryClient } from "@/lib/query-client";
import { AuthProvider } from "@/auth/auth-context";
Expand All @@ -10,6 +11,7 @@ import { ThemeProvider, useTheme } from "@/components/theme/theme-provider";
import { router } from "@/routes";

export function App() {
const { t } = useTranslation("common");
return (
<ThemeProvider>
<QueryClientProvider client={queryClient}>
Expand All @@ -22,7 +24,7 @@ export function App() {
fallback={
<div
role="status"
aria-label="Loading"
aria-label={t("loading.label")}
className="grid min-h-dvh place-items-center bg-[var(--color-background)]"
/>
}
Expand Down
35 changes: 35 additions & 0 deletions clients/admin/src/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export type UserDto = {
phoneNumber?: string | null;
imageUrl?: string | null;
twoFactorEnabled?: boolean;
/** Persisted BCP 47 UI language tag (e.g. "pt-BR"); null when the user never chose. */
locale?: string | null;
};

export type UserRoleDto = {
Expand Down Expand Up @@ -76,6 +78,39 @@ export async function setProfileImage(imageUrl: string | null): Promise<void> {
});
}

export type UpdateMyProfileInput = {
firstName?: string | null;
lastName?: string | null;
phoneNumber?: string | null;
/** BCP 47 UI language tag persisted on the user (drives the JWT locale claim). */
locale?: string | null;
};

/**
* Self-update of the authenticated user's profile (PUT /identity/profile,
* server forces the id to the caller). The backend sets FirstName/LastName
* unconditionally from the command, so a partial update (e.g. the language
* switcher sending only `locale`) would wipe the others. Reads the current
* profile from the server first and merges, so any field the caller omits
* keeps its persisted value instead of being nulled — never trust a possibly
* stale/undefined client-side snapshot for the echoed fields.
*/
export async function updateMyProfile(input: UpdateMyProfileInput): Promise<void> {
const profile = await getMyProfile();
await apiFetch<void>(`${IDENTITY}/profile`, {
method: "PUT",
body: JSON.stringify({
id: profile.id,
firstName: input.firstName ?? profile.firstName ?? null,
lastName: input.lastName ?? profile.lastName ?? null,
phoneNumber: input.phoneNumber ?? profile.phoneNumber ?? null,
locale: input.locale ?? profile.locale ?? null,
Comment thread
marcelo-maciel marked this conversation as resolved.
email: profile.email,
deleteCurrentImage: false,
}),
});
}

export async function changePassword(input: {
password: string;
newPassword: string;
Expand Down
7 changes: 5 additions & 2 deletions clients/admin/src/api/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import type { PagedResponse } from "@/lib/api-types";

// ─── shared enums ────────────────────────────────────────────────────

// Mirrors TopupRequestStatus in Modules.Billing.Contracts/BillingEnums.cs. "Approved" was never
// one of them: approving a request moves it to Invoiced.
export type TopupRequestStatus =
| "Pending"
| "Approved"
| "Rejected"
| "Invoiced"
| "Completed"
| "Rejected"
| "Cancelled"
| (string & {});

// ─── top-up requests ─────────────────────────────────────────────────
Expand Down
4 changes: 3 additions & 1 deletion clients/admin/src/auth/protected-route.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "@/auth/use-auth";
import { ForbiddenView } from "@/components/forbidden-view";

Expand All @@ -14,6 +15,7 @@ type ProtectedRouteProps = {
export function ProtectedRoute({ permissions = [] }: ProtectedRouteProps) {
const { isAuthenticated, isInitializing, user } = useAuth();
const location = useLocation();
const { t } = useTranslation("common");

// Resolving a stored session (silent token refresh) — hold rendering so we
// neither flash a protected surface with a stale/expired token nor bounce to
Expand All @@ -25,7 +27,7 @@ export function ProtectedRoute({ permissions = [] }: ProtectedRouteProps) {
role="status"
aria-busy="true"
>
<span className="sr-only">Restoring your session…</span>
<span className="sr-only">{t("protectedRoute.restoring")}</span>
<span
className="size-5 animate-spin rounded-full border-2 border-current border-t-transparent"
aria-hidden
Expand Down
4 changes: 3 additions & 1 deletion clients/admin/src/auth/route-guard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useAuth } from "@/auth/use-auth";
import { ForbiddenView } from "@/components/forbidden-view";

Expand All @@ -25,14 +26,15 @@ type RouteGuardProps = {
*/
export function RouteGuard({ perms, children }: RouteGuardProps) {
const { user, permissionsHydrated } = useAuth();
const { t } = useTranslation("common");

if (!permissionsHydrated) {
return (
<div
className="flex min-h-[60vh] items-center justify-center text-sm font-mono uppercase tracking-[0.18em] text-[var(--color-muted-foreground)]"
aria-busy
>
Resolving permissions
{t("routeGuard.resolving")}
<span className="caret text-[var(--color-accent-signal)]" aria-hidden />
</div>
);
Expand Down
8 changes: 5 additions & 3 deletions clients/admin/src/components/auth/auth-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { BrandMarkXL } from "@/components/brand-mark";
import { cn } from "@/lib/cn";

Expand Down Expand Up @@ -44,6 +45,7 @@ export function AuthShell({
/** Form area below the blurb. */
children: ReactNode;
}) {
const { t } = useTranslation("common");
return (
<div className="grid min-h-screen bg-[var(--color-background)] text-[var(--color-foreground)] lg:grid-cols-[1.1fr_1fr]">
{/* ─── Left pane — brand stage ───────────────────────────────── */}
Expand All @@ -65,11 +67,11 @@ export function AuthShell({
<BrandMarkXL className="fsh-enter fsh-enter-2 max-w-lg" />
<div className="fsh-enter fsh-enter-4 flex items-end justify-between gap-6">
<div className="space-y-1">
<div className="meta text-[var(--color-muted-foreground)]">authorized personnel</div>
<div className="meta text-[var(--color-muted-foreground)]">{t("authShell.authorizedPersonnel")}</div>
<div className="font-mono text-[12px] text-[var(--color-muted-foreground)] leading-relaxed">
Account recovery is rate-limited and audited.
{t("authShell.recoveryNotice")}
<br />
Reset links expire 30 minutes after issue.
{t("authShell.recoveryExpiry")}
</div>
</div>
<div className="meta text-right text-[var(--color-muted-foreground)]">
Expand Down
Loading
Loading