Phase D
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 8s
CI / Go — edge-gateway build (push) Successful in 17s

This commit is contained in:
smueller
2026-06-26 14:03:57 +02:00
parent ed8334328e
commit 333ad1cc7d
31 changed files with 964 additions and 15 deletions

View File

@@ -86,6 +86,12 @@
"securityTitle": "Sicherheit",
"securitySubtitle": "Authentifizierung und Sicherheitseinstellungen Ihres Kontos verwalten.",
"backToLogin": "Zurück zur Anmeldung",
"ssoTitle": "Anmeldung wird vorbereitet",
"ssoLoading": "Sicherer Anmeldelink wird geprüft…",
"ssoError": "Anmeldung fehlgeschlagen. Der Link ist abgelaufen oder wurde bereits verwendet.",
"ssoMissingTicket": "Es wurde kein Anmeldeticket übergeben.",
"impersonationBanner": "Support-Sitzung aktiv: {label}",
"impersonationDefaultLabel": "WHMCS-Admin-Impersonation",
"sessionNone": "Sitzung: keine",
"errors": {
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut."

View File

@@ -86,6 +86,12 @@
"securityTitle": "Security",
"securitySubtitle": "Manage authentication and security settings for your account.",
"backToLogin": "Back to sign in",
"ssoTitle": "Signing you in",
"ssoLoading": "Validating your secure sign-in link…",
"ssoError": "Sign-in failed. The link may be expired or already used.",
"ssoMissingTicket": "No sign-in ticket was provided.",
"impersonationBanner": "Support session active: {label}",
"impersonationDefaultLabel": "WHMCS admin impersonation",
"sessionNone": "session: none",
"errors": {
"generic": "Something went wrong. Please try again."

View File

@@ -0,0 +1,20 @@
import { Suspense } from "react";
import { setRequestLocale } from "next-intl/server";
import { SsoContent } from "@/components/auth/sso-content";
interface SsoPageProps {
params: Promise<{ locale: string }>;
}
export default async function SsoPage({ params }: SsoPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return (
<div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-6xl items-center justify-center px-4 py-12 sm:px-6">
<Suspense>
<SsoContent />
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import { useAuth } from "@/components/providers/auth-provider";
import { useTranslations } from "next-intl";
export function ImpersonationBanner() {
const { user } = useAuth();
const t = useTranslations("auth");
const impersonation = user?.impersonation;
if (!impersonation?.isImpersonation) {
return null;
}
return (
<div
className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-center text-sm text-amber-950 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100"
role="status"
>
{t("impersonationBanner", {
label: impersonation.label ?? t("impersonationDefaultLabel"),
})}
</div>
);
}

View File

@@ -0,0 +1,101 @@
"use client";
import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { Link, useRouter } from "@/i18n/navigation";
import { ApiError, consumeSsoTicket } from "@/lib/api/auth";
import { useAuth } from "@/components/providers/auth-provider";
type SsoState = "idle" | "loading" | "success" | "error";
export function SsoContent() {
const t = useTranslations("auth");
const searchParams = useSearchParams();
const router = useRouter();
const { refresh } = useAuth();
const ticket = searchParams.get("ticket");
const [state, setState] = useState<SsoState>(ticket ? "loading" : "idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (!ticket) {
return;
}
let cancelled = false;
async function redeem() {
try {
const result = await consumeSsoTicket(ticket!);
await refresh();
if (!cancelled) {
setState("success");
router.replace(result.redirectPath);
}
} catch (error) {
if (!cancelled) {
setState("error");
if (error instanceof ApiError) {
setErrorMessage(error.detail ?? error.title);
} else {
setErrorMessage(t("errors.generic"));
}
}
}
}
void redeem();
return () => {
cancelled = true;
};
}, [ticket, refresh, router, t]);
if (!ticket) {
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("ssoTitle")}</CardTitle>
<CardDescription>{t("ssoMissingTicket")}</CardDescription>
</CardHeader>
<CardContent>
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
{t("backToLogin")}
</Link>
</CardContent>
</Card>
);
}
if (state === "loading" || state === "idle" || state === "success") {
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("ssoTitle")}</CardTitle>
<CardDescription>{t("ssoLoading")}</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("ssoTitle")}</CardTitle>
<CardDescription>{t("ssoError")}</CardDescription>
</CardHeader>
<CardContent>
{errorMessage ? (
<p className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100" role="alert">
{errorMessage}
</p>
) : null}
<Link href="/login" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
{t("backToLogin")}
</Link>
</CardContent>
</Card>
);
}

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { ImpersonationBanner } from "@/components/auth/impersonation-banner";
import { Footer } from "./footer";
import { Header } from "./header";
@@ -10,6 +11,7 @@ export function SiteLayout({ children }: SiteLayoutProps) {
return (
<div className="flex min-h-screen flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50">
<Header />
<ImpersonationBanner />
<main className="flex-1">{children}</main>
<Footer />
</div>

View File

@@ -48,8 +48,23 @@ export interface CurrentUser {
username: string;
displayName?: string | null;
emailVerifiedAt?: string | null;
emailVerified?: boolean;
roles?: string[];
twoFactorEnabled?: boolean;
impersonation?: {
isImpersonation: boolean;
label?: string;
impersonatorLabel?: string;
};
}
export interface MeResponse {
user: CurrentUser;
impersonation?: {
isImpersonation: boolean;
impersonatorLabel?: string;
label?: string;
};
}
export interface TwoFactorSetupResponse {
@@ -149,8 +164,30 @@ export async function submitLogout(): Promise<void> {
});
}
export async function getCurrentUser(): Promise<CurrentUser> {
return apiFetch<CurrentUser>("/me");
export async function getCurrentUser(): Promise<CurrentUser | null> {
try {
const response = await apiFetch<MeResponse>("/me");
return {
...response.user,
impersonation: response.impersonation
? {
isImpersonation: response.impersonation.isImpersonation,
label:
response.impersonation.label ??
response.impersonation.impersonatorLabel,
}
: undefined,
};
} catch {
return null;
}
}
export async function consumeSsoTicket(ticket: string): Promise<{ redirectPath: string }> {
return apiFetch<{ redirectPath: string }>("/auth/sso/consume", {
method: "POST",
body: JSON.stringify({ ticket }),
});
}
export async function verifyEmail(token: string): Promise<void> {

File diff suppressed because one or more lines are too long