Phase D
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
20
apps/web/src/app/[locale]/auth/sso/page.tsx
Normal file
20
apps/web/src/app/[locale]/auth/sso/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/components/auth/impersonation-banner.tsx
Normal file
25
apps/web/src/components/auth/impersonation-banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
apps/web/src/components/auth/sso-content.tsx
Normal file
101
apps/web/src/components/auth/sso-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user