Skip to content
Merged
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
32 changes: 30 additions & 2 deletions clients/admin/src/auth/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { apiFetch } from "@/lib/api-client";
import { ApiRequestError, apiFetch } from "@/lib/api-client";

export type TokenResponse = {
accessToken: string;
Expand All @@ -11,13 +11,41 @@ export function issueToken(input: {
email: string;
password: string;
tenant: string;
/** Authenticator (TOTP) code — sent only on the second step of a 2FA sign-in. */
twoFactorCode?: string;
}) {
return apiFetch<TokenResponse>("/api/v1/identity/token/issue", {
method: "POST",
body: JSON.stringify({ email: input.email, password: input.password }),
body: JSON.stringify({
email: input.email,
password: input.password,
...(input.twoFactorCode ? { twoFactorCode: input.twoFactorCode } : {}),
}),
// X-FSH-App marks this client as the platform-admin app. Used by the
// API to enforce the SuperAdmin / dashboard boundary.
headers: { tenant: input.tenant, "X-FSH-App": "admin" },
skipAuth: true,
});
}

// Stable ProblemDetails `code`s the token endpoint sets for the second factor
// (the MessageKey IdentityService throws with). The detail text is localized
// under the request culture, so branch on the code, never on the prose.
const TWO_FACTOR_REQUIRED = "Identity.TwoFactorRequired";
const TWO_FACTOR_INVALID = "Identity.TwoFactorInvalid";

function problemCode(error: unknown): string | undefined {
if (!(error instanceof ApiRequestError) || error.status !== 401) return undefined;
const code = error.problem?.code;
return typeof code === "string" ? code : undefined;
}

/** The password was right, but the account has 2FA on and no code was sent. */
export function isTwoFactorRequiredError(error: unknown): boolean {
return problemCode(error) === TWO_FACTOR_REQUIRED;
}

/** The authenticator code was wrong or expired. */
export function isTwoFactorInvalidError(error: unknown): boolean {
return problemCode(error) === TWO_FACTOR_INVALID;
}
4 changes: 2 additions & 2 deletions clients/admin/src/auth/auth-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@
* permissions request is still in flight.
*/
permissionsHydrated: boolean;
login: (input: { email: string; password: string; tenant: string }) => Promise<void>;
login: (input: { email: string; password: string; tenant: string; twoFactorCode?: string }) => Promise<void>;
logout: () => void;
/** Re-fetch the permission set for the signed-in user. Call after a role
* assignment changes for the current user. */
refreshPermissions: () => Promise<void>;
};

export const AuthContext = createContext<AuthContextValue | null>(null);

Check warning on line 41 in clients/admin/src/auth/auth-context.tsx

View workflow job for this annotation

GitHub Actions / Lint & Build (admin)

Fast refresh only works when a file only exports components. Move your React context(s) to a separate file

function claimsToUser(claims: JwtClaims | null, permissions: string[]): AuthUser | null {
if (!claims?.sub) return null;
Expand Down Expand Up @@ -162,7 +162,7 @@
}, []);

const login = useCallback(
async (input: { email: string; password: string; tenant: string }) => {
async (input: { email: string; password: string; tenant: string; twoFactorCode?: string }) => {
tokenStore.setTenant(input.tenant);
// Stale permissions from a previous user must not leak into the new
// session — clear before issuing the token so the hydration effect
Expand Down
145 changes: 145 additions & 0 deletions clients/admin/src/components/auth/two-factor-step.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { useEffect, useRef, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { AlertCircle, ArrowLeft, ArrowRight, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/cn";

const TWO_FACTOR_CODE_LENGTH = 6;

type TwoFactorStepProps = {
email: string;
code: string;
onCodeChange: (code: string) => void;
onSubmit: () => void;
onBack: () => void;
submitting: boolean;
error: string | null;
};

/**
* Second step of the sign-in card, shown after the token call answers
* `Identity.TwoFactorRequired`. Collects the 6-digit authenticator code; the
* login page owns the credentials and re-issues the token call with it.
*/
export function TwoFactorStep({
email,
code,
onCodeChange,
onSubmit,
onBack,
submitting,
error,
}: TwoFactorStepProps) {
const { t } = useTranslation("auth");
const inputRef = useRef<HTMLInputElement>(null);

// Put the cursor back in the field after a rejected code (the page clears it).
useEffect(() => {
if (error && code === "") inputRef.current?.focus();
}, [error, code]);

const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (code.length === TWO_FACTOR_CODE_LENGTH && !submitting) onSubmit();
};

return (
<>
<div className="mb-6 sm:mb-8">
<h1 className="mb-1.5 font-display text-[22px] font-semibold tracking-tight text-[var(--color-foreground)]">
{t("twoFactor.title")}
</h1>
<p className="text-[13px] text-[var(--color-muted-foreground)]">
{t("twoFactor.subtitle")}
{email ? (
<>
{" "}
{t("twoFactor.subtitleAs")}{" "}
<span className="font-medium text-[var(--color-foreground)]">{email}</span>
</>
) : null}
.
</p>
</div>

<form
onSubmit={handleSubmit}
className="space-y-5"
noValidate
aria-describedby={error ? "two-factor-error" : undefined}
>
<div className="space-y-1.5">
<Label
htmlFor="two-factor-code"
className="block text-[11.5px] font-semibold uppercase tracking-wider text-[var(--color-muted-foreground)]"
>
{t("twoFactor.code")}
</Label>
<Input
ref={inputRef}
id="two-factor-code"
name="twoFactorCode"
value={code}
onChange={(e) => onCodeChange(e.target.value.replace(/\D/g, "").slice(0, TWO_FACTOR_CODE_LENGTH))}
inputMode="numeric"
autoComplete="one-time-code"
pattern="[0-9]*"
maxLength={TWO_FACTOR_CODE_LENGTH}
placeholder={t("twoFactor.codePlaceholder")}
autoFocus
required
aria-invalid={error ? true : undefined}
className="h-11 font-mono text-[16px] tracking-[0.4em]"
/>
</div>

{error && (
<div
id="two-factor-error"
role="alert"
className={cn(
"fsh-enter flex items-start gap-2 rounded-lg border px-3 py-2 text-sm",
"border-[oklch(from_var(--color-destructive)_l_c_h_/_0.30)]",
"bg-[oklch(from_var(--color-destructive)_l_c_h_/_0.06)]",
"text-[var(--color-destructive)]",
)}
>
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span className="leading-snug">{error}</span>
</div>
)}

<div className="space-y-3 pt-1.5">
<Button
type="submit"
disabled={submitting || code.length !== TWO_FACTOR_CODE_LENGTH}
className="group h-11 w-full text-[14px] font-semibold"
>
{submitting ? (
<>
<Loader2 className="size-4 animate-spin" />
<span>{t("twoFactor.submitting")}</span>
</>
) : (
<>
<span>{t("twoFactor.submit")}</span>
<ArrowRight className="size-[14px] opacity-60 transition-all duration-200 group-hover:translate-x-0.5 group-hover:opacity-100" />
</>
)}
</Button>
<button
type="button"
onClick={onBack}
disabled={submitting}
className="mx-auto flex cursor-pointer items-center gap-1.5 text-[12px] font-medium text-[var(--color-muted-foreground)] underline-offset-4 transition-colors hover:text-[var(--color-primary)] hover:underline disabled:pointer-events-none disabled:opacity-50"
>
<ArrowLeft className="size-3" />
<span>{t("twoFactor.back")}</span>
</button>
</div>
</form>
</>
);
}
10 changes: 10 additions & 0 deletions clients/admin/src/locales/en-US/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@
"login.demoButton": "Sign in with a demo account",
"login.footer": "fullstackhero Administration",

"twoFactor.title": "Two-step verification",
"twoFactor.subtitle": "Enter the 6-digit code from your authenticator app to finish signing in",
"twoFactor.subtitleAs": "as",
"twoFactor.code": "Authentication code",
"twoFactor.codePlaceholder": "123456",
"twoFactor.submit": "Verify",
"twoFactor.submitting": "Verifying…",
"twoFactor.back": "Back to sign in",
"twoFactor.invalidCode": "That code didn't work. Check your authenticator app and try again.",

"demo.badge": "Dev · demo",
"demo.title": "Sign in as operator.",
"demo.subtitle": "Tap an account below — we'll fill the credentials and sign you in instantly.",
Expand Down
9 changes: 9 additions & 0 deletions clients/admin/src/locales/pt-BR/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@
"login.submitting": "Entrando…",
"login.demoButton": "Entrar com uma conta de demonstração",
"login.footer": "Administração do fullstackhero",
"twoFactor.title": "Verificação em duas etapas",
"twoFactor.subtitle": "Digite o código de 6 dígitos do seu aplicativo autenticador para concluir o login",
"twoFactor.subtitleAs": "como",
"twoFactor.code": "Código de autenticação",
"twoFactor.codePlaceholder": "123456",
"twoFactor.submit": "Verificar",
"twoFactor.submitting": "Verificando…",
"twoFactor.back": "Voltar para o login",
"twoFactor.invalidCode": "Esse código não funcionou. Confira seu aplicativo autenticador e tente novamente.",
"demo.badge": "Dev · demo",
"demo.title": "Entrar como operador.",
"demo.subtitle": "Toque em uma conta abaixo — preenchemos as credenciais e entramos na hora.",
Expand Down
Loading
Loading