Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
23989b1
feat(dashboard): localize the tenant app with react-i18next
marcelo-maciel Aug 17, 2026
e8c0693
build(deps): bump Testcontainers to 4.14.0 and SourceLink past their …
marcelo-maciel Sep 14, 2026
b13fb9f
fix(infra): pull MinIO from quay.io on a pinned tag, not Docker Hub
marcelo-maciel Sep 14, 2026
eb0d38d
fix(dashboard): pass the unit token to the count chip, not a translat…
marcelo-maciel Sep 17, 2026
193dc83
fix(dashboard): keep the session when a speculative refresh fails
marcelo-maciel Sep 18, 2026
f24834d
test(dashboard): gate interpolation parity, not just keys
marcelo-maciel Sep 18, 2026
82b4afb
feat(dashboard): localize the command palette search keywords
marcelo-maciel Sep 18, 2026
e979f1e
fix(dashboard): tell the user when the language could not be saved
marcelo-maciel Sep 18, 2026
d3d4474
test(dashboard): cover the failed-save toast
marcelo-maciel Sep 18, 2026
012ecca
fix(dashboard): stop a raw catalog key from reaching the screen
marcelo-maciel Sep 18, 2026
86f488b
fix(infra): pull minio/mc from quay.io too, not just minio/minio
marcelo-maciel Sep 18, 2026
0f5e6b7
build(deps): drop the dead SSH.NET pin
marcelo-maciel Sep 18, 2026
57586b7
fix(i18n): stop the missing-key handler from eating every defaultValue
marcelo-maciel Sep 18, 2026
0fd8b3f
fix(dashboard): format counts as numbers, and localize upload failures
marcelo-maciel Sep 18, 2026
d966da7
fix(dashboard): let a lost upload key fall through to the segment fal…
marcelo-maciel Sep 18, 2026
4f5a99c
fix(i18n): namespace the language key and localize the last upload path
marcelo-maciel Sep 18, 2026
70664b1
test(i18n): pin the phone number the language switch echoes back
marcelo-maciel Sep 18, 2026
f7d2708
Merge origin/main into feat/i18n-dashboard
marcelo-maciel Sep 25, 2026
d46c10f
fix(dashboard): keep a clean profile form on the current ETag across …
marcelo-maciel Sep 25, 2026
dd2d763
Merge origin/main into feat/i18n-dashboard
marcelo-maciel Sep 25, 2026
49b7241
fix(dashboard): format the file preview's created date in the app lan…
marcelo-maciel Sep 25, 2026
a918787
Merge branch 'main' into feat/i18n-dashboard
marcelo-maciel Sep 26, 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
103 changes: 103 additions & 0 deletions clients/dashboard/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/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"i18next": "^26.3.6",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.475.0",
"qrcode": "^1.5.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.10",
"react-router-dom": "^7.18.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0"
Expand Down
7 changes: 7 additions & 0 deletions clients/dashboard/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ export default defineConfig({
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 (a second suite running, a loaded dev server) the first
// paint of a lazy route lands past 5s while staying well inside the action and
// navigation budgets. That asymmetry, not the specs, is what made runs flaky.
expect: { timeout: 10_000 },

projects: [
{
name: "chromium",
Expand Down
3 changes: 2 additions & 1 deletion clients/dashboard/public/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"defaultTenant": "root",
"demoMode": true,
"inactivityIdleMs": 1200000,
"inactivityWarningMs": 60000
"inactivityWarningMs": 60000,
"defaultLanguage": "en-US"
}
4 changes: 4 additions & 0 deletions clients/dashboard/src/api/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type UserDto = {
phoneNumber?: string;
imageUrl?: string;
twoFactorEnabled?: boolean;
locale?: string | null;
};

export type UserRoleDto = {
Expand Down Expand Up @@ -398,6 +399,8 @@ export type UpdateProfileInput = {
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;
};

/**
Expand Down Expand Up @@ -447,6 +450,7 @@ export async function updateMyProfile(input: UpdateProfileInput): Promise<void>
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,
}),
Expand Down
21 changes: 17 additions & 4 deletions clients/dashboard/src/auth/impersonation-handoff.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,33 @@
import { tokenStore } from "@/auth/token-store";
import i18n, { SUPPORTED } from "@/i18n";

/**
* Cross-app impersonation handoff. The admin app issues an impersonation
* access token server-side, then opens the dashboard with the token in the
* URL hash:
*
* https://dashboard.example.com/#impersonate?token=<jwt>&tenant=<id>&expiresAt=<iso>
* https://dashboard.example.com/#impersonate?token=<jwt>&tenant=<id>&expiresAt=<iso>&locale=<tag>
*
* We use the hash (not query) for two reasons:
* 1. Browsers never send the fragment in HTTP requests, so the token can't
* leak via referrer headers or server access logs.
* 2. SPA hash routes are already a thing — the bootstrap can scrub the
* hash before any router runs, without touching the path.
*
* Call this synchronously in main.tsx BEFORE createRoot so the token is
* installed before AuthProvider's first render — otherwise ProtectedRoute
* Await this in main.tsx BEFORE createRoot so both the token and the language
* are installed before AuthProvider's first render — otherwise ProtectedRoute
* would see an anonymous session, redirect to /login, and the user would
* have to sign in even though we have a valid impersonation token.
*/
export function installImpersonationFromHash(): void {
export async function installImpersonationFromHash(): Promise<void> {
if (typeof window === "undefined") return;
const hash = window.location.hash;
if (!hash.startsWith("#impersonate?")) return;

const params = new URLSearchParams(hash.slice("#impersonate?".length));
const token = params.get("token");
const tenant = params.get("tenant");
const locale = params.get("locale");
if (!token) {
// Malformed handoff — strip the hash and let the normal sign-in flow
// take over rather than getting stuck.
Expand All @@ -40,6 +42,17 @@ export function installImpersonationFromHash(): void {
// which mints a real actor token+refresh for the admin operator's account.
tokenStore.beginImpersonation(token, tenant);
stripHash();

// Adopt the operator's language for the impersonation session. The server
// strips the target's `locale` claim on purpose, and this app is normally on a
// different origin than admin, so the handoff parameter is the only channel
// that carries the operator's choice. Applying it here also fixes the API
// side: apiFetch derives Accept-Language from i18n.language, so responses come
// back in the operator's language instead of the browser-detected one.
// Unsupported or absent tags are ignored, leaving normal detection in place.
if (locale && (SUPPORTED as readonly string[]).includes(locale) && i18n.language !== locale) {
await i18n.changeLanguage(locale);
}
}

function stripHash(): void {
Expand Down
4 changes: 3 additions & 1 deletion clients/dashboard/src/auth/protected-route.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "@/auth/use-auth";

export function ProtectedRoute() {
const { t } = useTranslation("common");
const { isAuthenticated, isInitializing } = useAuth();
const location = useLocation();

Expand All @@ -15,7 +17,7 @@ export function ProtectedRoute() {
role="status"
aria-busy="true"
>
<span className="sr-only">Restoring your session…</span>
<span className="sr-only">{t("session.restoring")}</span>
<span
className="size-5 animate-spin rounded-full border-2 border-current border-t-transparent"
aria-hidden
Expand Down
8 changes: 5 additions & 3 deletions clients/dashboard/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 { ShieldCheck } from "lucide-react";

// ────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -42,6 +43,7 @@ export function AuthShell({
/** Optional row beneath the card — e.g. "Back to sign in" link */
footer?: ReactNode;
}) {
const { t } = useTranslation("auth");
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[var(--color-background)] px-5 py-8 sm:py-12">
{/* Atmospheric background — three rose/saffron blur orbs at
Expand Down Expand Up @@ -77,7 +79,7 @@ export function AuthShell({
</div>
<div className="mt-3 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-[oklch(from_var(--color-muted-foreground)_l_c_h_/_0.7)]">
<span aria-hidden className="h-px w-6 bg-[var(--color-border)]" />
<span>.NET 10 Starter Kit</span>
<span>{t("shell.tagline")}</span>
<span aria-hidden className="h-px w-6 bg-[var(--color-border)]" />
</div>
</div>
Expand All @@ -95,10 +97,10 @@ export function AuthShell({

<div className="mt-6 flex items-center justify-center gap-1.5 text-[11px] text-[var(--color-muted-foreground)]">
<ShieldCheck className="size-3" />
<span>Encrypted in transit · JWT-secured session</span>
<span>{t("shell.encrypted")}</span>
</div>
<p className="mt-4 text-center text-[10px] font-medium uppercase tracking-wider text-[oklch(from_var(--color-muted-foreground)_l_c_h_/_0.5)]">
fullstackhero Administration
{t("shell.footer")}
</p>
</div>
</div>
Expand Down
26 changes: 15 additions & 11 deletions clients/dashboard/src/components/auth/demo-accounts-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUpRight } from "lucide-react";
import {
Dialog,
Expand Down Expand Up @@ -30,6 +31,7 @@ interface DemoAccountsDialogProps {
}

export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsDialogProps) {
const { t } = useTranslation("auth");
const tenants = DEMO_ACCOUNT_GROUPS;
const [activeIdx, setActiveIdx] = useState(0);

Expand All @@ -48,9 +50,9 @@ export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsD
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden rounded-2xl border-border/70 p-0 sm:max-w-[680px]">
<DialogTitle className="sr-only">Demo accounts</DialogTitle>
<DialogTitle className="sr-only">{t("demo.dialogTitle")}</DialogTitle>
<DialogDescription className="sr-only">
Pick a demo tenant and account to sign in with.
{t("demo.dialogDescription")}
</DialogDescription>

{/* Atmospheric gradients */}
Expand All @@ -71,14 +73,14 @@ export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsD
<span className="relative inline-flex size-1.5 rounded-full bg-primary" />
</span>
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.22em] text-primary/85">
Live demo
{t("demo.liveDemo")}
</span>
</div>
<h2 className="font-display text-[22px] font-semibold leading-[1.15] tracking-[-0.01em] text-foreground">
Step into any role.
{t("demo.title")}
</h2>
<p className="mt-1.5 max-w-[80%] text-[12.5px] leading-relaxed text-muted-foreground/80">
Explore fullstackhero as any user across the demo tenants — we'll sign you in instantly.
{t("demo.subtitle")}
</p>
</header>

Expand All @@ -96,9 +98,9 @@ export function DemoAccountsDialog({ open, onOpenChange, onPick }: DemoAccountsD
{/* Footer */}
<div className="relative flex items-center justify-between border-t border-border/60 bg-background/40 px-7 py-3">
<p className="text-[10.5px] tracking-wide text-muted-foreground/60">
<span className="font-mono text-muted-foreground/80">demo only</span>
<span className="font-mono text-muted-foreground/80">{t("demo.demoOnly")}</span>
<span className="mx-2 text-muted-foreground/30">·</span>
Resets with every reseed.
{t("demo.resets")}
</p>
<kbd className="hidden items-center gap-1 rounded border border-border/60 bg-background/60 px-1.5 py-0.5 font-mono text-[9.5px] text-muted-foreground/60 sm:inline-flex">
esc
Expand All @@ -122,10 +124,11 @@ function TenantRail({
activeIdx: number;
onSelect: (idx: number) => void;
}) {
const { t } = useTranslation("auth");
return (
<nav
className="relative flex gap-1 overflow-x-auto p-3 sm:flex-col sm:overflow-visible"
aria-label="Demo tenants"
aria-label={t("demo.tenantsAria")}
>
{tenants.map((tenant, i) => {
const isActive = i === activeIdx;
Expand Down Expand Up @@ -165,7 +168,7 @@ function TenantRail({
{tenant.tenantLabel}
</span>
<span className="mt-0.5 block font-mono text-[10px] uppercase tracking-wider text-muted-foreground/55">
{tenant.accounts.length} users
{t("demo.userCount", { count: tenant.accounts.length })}
</span>
</span>
</button>
Expand All @@ -184,15 +187,16 @@ function UserPane({
tenant: DemoTenant;
onPick: (account: DemoAccount) => void;
}) {
const { t } = useTranslation("auth");
return (
<div className="fsh-enter max-h-[340px] overflow-y-auto p-3">
<div className="mb-1 flex items-baseline gap-2 px-2 pb-2">
<span className="font-mono text-[9.5px] font-semibold uppercase tabular-nums tracking-[0.18em] text-primary/55">
Users
{t("demo.users")}
</span>
<div className="relative top-[-2px] h-px flex-1 bg-border/70" />
<span className="font-mono text-[9.5px] uppercase tracking-[0.15em] text-muted-foreground/50">
tap to sign in
{t("demo.tapToSignIn")}
</span>
</div>
<div className="space-y-0.5">
Expand Down
Loading
Loading