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/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.15.1",
"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"
}
8 changes: 7 additions & 1 deletion 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 @@ -394,14 +395,18 @@ 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;
};

/**
* Updates the authenticated user's profile. Maps to UpdateUserCommand
* server-side. Image and email changes go through their own dedicated
* endpoints — this is for the editable profile fields surfaced in
* settings/profile. Reads the current profile first so unset optional
* fields keep their existing values instead of being nulled.
* fields keep their existing values instead of being nulled — the backend
* sets FirstName/LastName unconditionally from the command, so a locale-only
* save (the language switcher) would otherwise wipe the names.
*/
export async function updateMyProfile(input: UpdateProfileInput): Promise<void> {
const profile = await getMyProfile();
Expand All @@ -412,6 +417,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
Loading
Loading