Skip to content
Open
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
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,
email: profile.email,
deleteCurrentImage: false,
}),
});
}

export async function changePassword(input: {
password: string;
newPassword: string;
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