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

This commit is contained in:
smueller
2026-06-26 11:31:54 +02:00
parent 4fe25e6ec3
commit 58961000eb
123 changed files with 4231 additions and 136 deletions

View File

@@ -44,12 +44,51 @@
"registerSubtitle": "Registrieren Sie sich für die Plattform.",
"email": "E-Mail",
"emailPlaceholder": "name@beispiel.de",
"username": "Benutzername",
"usernamePlaceholder": "spieler123",
"displayName": "Anzeigename",
"displayNamePlaceholder": "Ihr Name",
"optional": "optional",
"password": "Passwort",
"passwordPlaceholder": "••••••••",
"confirmPassword": "Passwort bestätigen",
"noAccount": "Noch kein Konto?",
"hasAccount": "Bereits registriert?",
"apiPending": "Authentifizierung wird in Phase 1 angebunden. Formularstruktur ist vorbereitet."
"forgotPasswordLink": "Passwort vergessen?",
"forgotPasswordTitle": "Passwort zurücksetzen",
"forgotPasswordSubtitle": "Geben Sie Ihre E-Mail-Adresse ein. Wir senden Ihnen einen Link zum Zurücksetzen.",
"forgotPasswordSubmit": "Link senden",
"forgotPasswordSent": "Falls ein Konto mit dieser E-Mail existiert, erhalten Sie in Kürze einen Link.",
"resetPasswordTitle": "Neues Passwort festlegen",
"resetPasswordSubtitle": "Wählen Sie ein neues Passwort für Ihr Konto.",
"resetPasswordSubmit": "Passwort aktualisieren",
"resetPasswordMissingToken": "Dieser Link ist ungültig oder abgelaufen.",
"verifyEmailTitle": "E-Mail-Verifizierung",
"verifyEmailSent": "Registrierung erfolgreich. Bitte prüfen Sie Ihren Posteingang und klicken Sie auf den Verifizierungslink.",
"verifyEmailLoading": "E-Mail-Adresse wird verifiziert…",
"verifyEmailSuccess": "Ihre E-Mail-Adresse wurde verifiziert. Sie können sich jetzt anmelden.",
"verifyEmailError": "Verifizierung fehlgeschlagen. Der Link ist möglicherweise ungültig oder abgelaufen.",
"verifyEmailMissingToken": "Kein Verifizierungstoken angegeben.",
"twoFactorTitle": "Zwei-Faktor-Authentifizierung",
"twoFactorSubtitle": "Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein.",
"twoFactorCode": "Authentifizierungscode",
"twoFactorSubmit": "Bestätigen",
"twoFactorMissingChallenge": "Keine Anmelde-Challenge gefunden. Bitte melden Sie sich erneut an.",
"twoFactorSetupTitle": "Zwei-Faktor-Authentifizierung",
"twoFactorSetupDescription": "Schützen Sie Ihr Konto mit zusätzlicher TOTP-Sicherheit.",
"twoFactorSetupStart": "2FA einrichten",
"twoFactorScanQr": "Scannen Sie diesen QR-Code mit Ihrer Authenticator-App und geben Sie den Code ein.",
"twoFactorQrAlt": "QR-Code für Authenticator-Einrichtung",
"twoFactorConfirm": "2FA aktivieren",
"twoFactorEnabled": "Zwei-Faktor-Authentifizierung ist für Ihr Konto aktiviert.",
"recoveryCodesTitle": "Wiederherstellungscodes — sicher aufbewahren",
"securityTitle": "Sicherheit",
"securitySubtitle": "Authentifizierung und Sicherheitseinstellungen Ihres Kontos verwalten.",
"backToLogin": "Zurück zur Anmeldung",
"sessionNone": "Sitzung: keine",
"errors": {
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut."
}
},
"dashboard": {
"title": "Dashboard",
@@ -61,8 +100,7 @@
"status": "Status",
"resources": "Ressourcen",
"activity": "Aktivität",
"activityEmpty": "Keine aktuellen Ereignisse",
"protectedNote": "Geschützter Bereich — Authentifizierung wird in Phase 1 aktiviert."
"activityEmpty": "Keine aktuellen Ereignisse"
},
"validation": {
"emailRequired": "E-Mail ist erforderlich",
@@ -70,6 +108,9 @@
"passwordRequired": "Passwort ist erforderlich",
"passwordMin": "Passwort muss mindestens 8 Zeichen haben",
"confirmPasswordRequired": "Passwortbestätigung ist erforderlich",
"passwordMismatch": "Passwörter stimmen nicht überein"
"passwordMismatch": "Passwörter stimmen nicht überein",
"usernameInvalid": "Benutzername: 332 Zeichen (Buchstaben, Zahlen, _ und -)",
"codeRequired": "Authentifizierungscode ist erforderlich",
"codeInvalid": "Geben Sie einen gültigen 6-stelligen Code ein"
}
}

View File

@@ -44,12 +44,51 @@
"registerSubtitle": "Register for the platform.",
"email": "Email",
"emailPlaceholder": "name@example.com",
"username": "Username",
"usernamePlaceholder": "player123",
"displayName": "Display name",
"displayNamePlaceholder": "Your name",
"optional": "optional",
"password": "Password",
"passwordPlaceholder": "••••••••",
"confirmPassword": "Confirm password",
"noAccount": "No account yet?",
"hasAccount": "Already registered?",
"apiPending": "Authentication will be connected in Phase 1. Form structure is ready."
"forgotPasswordLink": "Forgot password?",
"forgotPasswordTitle": "Reset password",
"forgotPasswordSubtitle": "Enter your email address and we will send you a reset link.",
"forgotPasswordSubmit": "Send reset link",
"forgotPasswordSent": "If an account exists for this email, you will receive a reset link shortly.",
"resetPasswordTitle": "Set new password",
"resetPasswordSubtitle": "Choose a new password for your account.",
"resetPasswordSubmit": "Update password",
"resetPasswordMissingToken": "This reset link is invalid or has expired.",
"verifyEmailTitle": "Email verification",
"verifyEmailSent": "Registration successful. Please check your inbox and click the verification link.",
"verifyEmailLoading": "Verifying your email address…",
"verifyEmailSuccess": "Your email address has been verified. You can now sign in.",
"verifyEmailError": "Verification failed. The link may be invalid or expired.",
"verifyEmailMissingToken": "No verification token was provided.",
"twoFactorTitle": "Two-factor authentication",
"twoFactorSubtitle": "Enter the 6-digit code from your authenticator app.",
"twoFactorCode": "Authentication code",
"twoFactorSubmit": "Verify",
"twoFactorMissingChallenge": "No login challenge found. Please sign in again.",
"twoFactorSetupTitle": "Two-factor authentication",
"twoFactorSetupDescription": "Add an extra layer of security to your account with TOTP.",
"twoFactorSetupStart": "Set up 2FA",
"twoFactorScanQr": "Scan this QR code with your authenticator app, then enter the generated code.",
"twoFactorQrAlt": "QR code for authenticator setup",
"twoFactorConfirm": "Enable 2FA",
"twoFactorEnabled": "Two-factor authentication is enabled on your account.",
"recoveryCodesTitle": "Recovery codes — save these in a secure place",
"securityTitle": "Security",
"securitySubtitle": "Manage authentication and security settings for your account.",
"backToLogin": "Back to sign in",
"sessionNone": "session: none",
"errors": {
"generic": "Something went wrong. Please try again."
}
},
"dashboard": {
"title": "Dashboard",
@@ -61,8 +100,7 @@
"status": "Status",
"resources": "Resources",
"activity": "Activity",
"activityEmpty": "No recent events",
"protectedNote": "Protected area — authentication will be enabled in Phase 1."
"activityEmpty": "No recent events"
},
"validation": {
"emailRequired": "Email is required",
@@ -70,6 +108,9 @@
"passwordRequired": "Password is required",
"passwordMin": "Password must be at least 8 characters",
"confirmPasswordRequired": "Password confirmation is required",
"passwordMismatch": "Passwords do not match"
"passwordMismatch": "Passwords do not match",
"usernameInvalid": "Username must be 332 characters (letters, numbers, _ and -)",
"codeRequired": "Authentication code is required",
"codeInvalid": "Enter a valid 6-digit code"
}
}

View File

@@ -1,6 +1,7 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import type { ReactNode } from "react";
import { Link } from "@/i18n/navigation";
import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info";
interface DashboardLayoutProps {
children: ReactNode;
@@ -31,9 +32,7 @@ export default async function DashboardLayout({
</li>
</ul>
</nav>
<p className="font-mono text-xs text-zinc-500">
auth: pending · session: none
</p>
<DashboardUserInfo />
</div>
{children}
</div>

View File

@@ -0,0 +1,20 @@
import { Suspense } from "react";
import { setRequestLocale } from "next-intl/server";
import { ForgotPasswordForm } from "@/components/auth/forgot-password-form";
interface ForgotPasswordPageProps {
params: Promise<{ locale: string }>;
}
export default async function ForgotPasswordPage({ params }: ForgotPasswordPageProps) {
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>
<ForgotPasswordForm />
</Suspense>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { getMessages, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import { SiteLayout } from "@/components/layout/site-layout";
import { AuthProvider } from "@/components/providers/auth-provider";
import { QueryProvider } from "@/components/providers/query-provider";
import { ThemeProvider } from "@/components/providers/theme-provider";
import { routing, type Locale } from "@/i18n/routing";
@@ -30,7 +31,9 @@ export default async function LocaleLayout({ children, params }: LocaleLayoutPro
<NextIntlClientProvider messages={messages}>
<ThemeProvider>
<QueryProvider>
<SiteLayout>{children}</SiteLayout>
<AuthProvider>
<SiteLayout>{children}</SiteLayout>
</AuthProvider>
</QueryProvider>
</ThemeProvider>
</NextIntlClientProvider>

View File

@@ -0,0 +1,20 @@
import { Suspense } from "react";
import { setRequestLocale } from "next-intl/server";
import { Login2FaForm } from "@/components/auth/login-2fa-form";
interface Login2FaPageProps {
params: Promise<{ locale: string }>;
}
export default async function Login2FaPage({ params }: Login2FaPageProps) {
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>
<Login2FaForm />
</Suspense>
</div>
);
}

View File

@@ -1,3 +1,4 @@
import { Suspense } from "react";
import { setRequestLocale } from "next-intl/server";
import { LoginForm } from "@/components/auth/login-form";
@@ -11,7 +12,9 @@ export default async function LoginPage({ params }: LoginPageProps) {
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">
<LoginForm />
<Suspense>
<LoginForm />
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { Suspense } from "react";
import { setRequestLocale } from "next-intl/server";
import { ResetPasswordForm } from "@/components/auth/reset-password-form";
interface ResetPasswordPageProps {
params: Promise<{ locale: string }>;
}
export default async function ResetPasswordPage({ params }: ResetPasswordPageProps) {
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>
<ResetPasswordForm />
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { setRequestLocale } from "next-intl/server";
import { SecurityContent } from "@/components/auth/security-content";
interface SecurityPageProps {
params: Promise<{ locale: string }>;
}
export default async function SecurityPage({ params }: SecurityPageProps) {
const { locale } = await params;
setRequestLocale(locale);
return (
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
<SecurityContent />
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { Suspense } from "react";
import { setRequestLocale } from "next-intl/server";
import { VerifyEmailContent } from "@/components/auth/verify-email-content";
interface VerifyEmailPageProps {
params: Promise<{ locale: string }>;
}
export default async function VerifyEmailPage({ params }: VerifyEmailPageProps) {
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>
<VerifyEmailContent />
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Link } from "@/i18n/navigation";
import { ApiError, forgotPassword } from "@/lib/api/auth";
import { createForgotPasswordSchema, type ForgotPasswordFormValues } from "@/lib/schemas/auth";
const inputClassName =
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
export function ForgotPasswordForm() {
const t = useTranslations("auth");
const tv = useTranslations("validation");
const [apiError, setApiError] = useState<string | null>(null);
const [submitted, setSubmitted] = useState(false);
const schema = createForgotPasswordSchema({
emailRequired: tv("emailRequired"),
emailInvalid: tv("emailInvalid"),
});
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ForgotPasswordFormValues>({
resolver: zodResolver(schema),
defaultValues: { email: "" },
});
async function onSubmit(values: ForgotPasswordFormValues) {
setApiError(null);
try {
await forgotPassword(values.email);
setSubmitted(true);
} catch (error) {
if (error instanceof ApiError) {
setApiError(error.detail ?? error.title);
} else {
setApiError(t("errors.generic"));
}
}
}
if (submitted) {
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("forgotPasswordTitle")}</CardTitle>
<CardDescription>{t("forgotPasswordSent")}</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>
);
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("forgotPasswordTitle")}</CardTitle>
<CardDescription>{t("forgotPasswordSubtitle")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("email")}
</label>
<input
id="email"
type="email"
autoComplete="email"
placeholder={t("emailPlaceholder")}
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined}
className={inputClassName}
{...register("email")}
/>
{errors.email ? (
<p id="email-error" className="text-sm text-red-600" role="alert">
{errors.email.message}
</p>
) : null}
</div>
{apiError ? (
<p className="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">
{apiError}
</p>
) : null}
<Button type="submit" className="w-full" isLoading={isSubmitting}>
{t("forgotPasswordSubmit")}
</Button>
</form>
<p className="mt-4 text-center text-sm text-zinc-600 dark:text-zinc-400">
<Link href="/login" className="font-medium text-sky-600 hover:underline dark:text-sky-400">
{t("backToLogin")}
</Link>
</p>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,123 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Link, useRouter } from "@/i18n/navigation";
import { ApiError, verifyTwoFactorLogin } from "@/lib/api/auth";
import { createTwoFactorSchema, type TwoFactorFormValues } from "@/lib/schemas/auth";
import { useAuth } from "@/components/providers/auth-provider";
const inputClassName =
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
export function Login2FaForm() {
const t = useTranslations("auth");
const tv = useTranslations("validation");
const router = useRouter();
const searchParams = useSearchParams();
const { refresh } = useAuth();
const [apiError, setApiError] = useState<string | null>(null);
const challengeId = searchParams.get("challenge");
const schema = createTwoFactorSchema({
codeRequired: tv("codeRequired"),
codeInvalid: tv("codeInvalid"),
});
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TwoFactorFormValues>({
resolver: zodResolver(schema),
defaultValues: { code: "" },
});
if (!challengeId) {
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("twoFactorTitle")}</CardTitle>
<CardDescription>{t("twoFactorMissingChallenge")}</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>
);
}
async function onSubmit(values: TwoFactorFormValues) {
setApiError(null);
try {
await verifyTwoFactorLogin(challengeId!, values.code);
await refresh();
router.push("/dashboard");
} catch (error) {
if (error instanceof ApiError) {
setApiError(error.detail ?? error.title);
} else {
setApiError(t("errors.generic"));
}
}
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("twoFactorTitle")}</CardTitle>
<CardDescription>{t("twoFactorSubtitle")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<label htmlFor="code" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("twoFactorCode")}
</label>
<input
id="code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="000000"
maxLength={6}
aria-invalid={errors.code ? "true" : "false"}
aria-describedby={errors.code ? "code-error" : undefined}
className={inputClassName}
{...register("code")}
/>
{errors.code ? (
<p id="code-error" className="text-sm text-red-600" role="alert">
{errors.code.message}
</p>
) : null}
</div>
{apiError ? (
<p className="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">
{apiError}
</p>
) : null}
<Button type="submit" className="w-full" isLoading={isSubmitting}>
{t("twoFactorSubmit")}
</Button>
</form>
<p className="mt-4 text-center text-sm text-zinc-600 dark:text-zinc-400">
<Link href="/login" className="font-medium text-sky-600 hover:underline dark:text-sky-400">
{t("backToLogin")}
</Link>
</p>
</CardContent>
</Card>
);
}

View File

@@ -2,17 +2,25 @@
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Link } from "@/i18n/navigation";
import { submitLogin } from "@/lib/api/auth";
import { Link, useRouter } from "@/i18n/navigation";
import { ApiError, submitLogin } from "@/lib/api/auth";
import { createLoginSchema, type LoginFormValues } from "@/lib/schemas/auth";
import { useAuth } from "@/components/providers/auth-provider";
const inputClassName =
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
export function LoginForm() {
const t = useTranslations("auth");
const tv = useTranslations("validation");
const [submitted, setSubmitted] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const { refresh } = useAuth();
const [apiError, setApiError] = useState<string | null>(null);
const schema = createLoginSchema({
emailRequired: tv("emailRequired"),
@@ -31,8 +39,26 @@ export function LoginForm() {
});
async function onSubmit(values: LoginFormValues) {
await submitLogin(values);
setSubmitted(true);
setApiError(null);
try {
const result = await submitLogin(values);
if (result.twoFactorRequired && result.challengeId) {
router.push(`/login/2fa?challenge=${encodeURIComponent(result.challengeId)}`);
return;
}
await refresh();
const redirectTo = searchParams.get("redirect");
router.push(redirectTo?.startsWith("/") ? redirectTo : "/dashboard");
} catch (error) {
if (error instanceof ApiError) {
setApiError(error.detail ?? error.title);
} else {
setApiError(t("errors.generic"));
}
}
}
return (
@@ -54,7 +80,7 @@ export function LoginForm() {
placeholder={t("emailPlaceholder")}
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined}
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
className={inputClassName}
{...register("email")}
/>
{errors.email ? (
@@ -65,9 +91,17 @@ export function LoginForm() {
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("password")}
</label>
<div className="flex items-center justify-between">
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("password")}
</label>
<Link
href="/forgot-password"
className="text-xs font-medium text-sky-600 hover:underline dark:text-sky-400"
>
{t("forgotPasswordLink")}
</Link>
</div>
<input
id="password"
type="password"
@@ -75,7 +109,7 @@ export function LoginForm() {
placeholder={t("passwordPlaceholder")}
aria-invalid={errors.password ? "true" : "false"}
aria-describedby={errors.password ? "password-error" : undefined}
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
className={inputClassName}
{...register("password")}
/>
{errors.password ? (
@@ -85,9 +119,9 @@ export function LoginForm() {
) : null}
</div>
{submitted ? (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
{t("apiPending")}
{apiError ? (
<p className="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">
{apiError}
</p>
) : null}

View File

@@ -5,14 +5,18 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Link } from "@/i18n/navigation";
import { submitRegister } from "@/lib/api/auth";
import { Link, useRouter } from "@/i18n/navigation";
import { ApiError, submitRegister } from "@/lib/api/auth";
import { createRegisterSchema, type RegisterFormValues } from "@/lib/schemas/auth";
const inputClassName =
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
export function RegisterForm() {
const t = useTranslations("auth");
const tv = useTranslations("validation");
const [submitted, setSubmitted] = useState(false);
const router = useRouter();
const [apiError, setApiError] = useState<string | null>(null);
const schema = createRegisterSchema({
emailRequired: tv("emailRequired"),
@@ -21,6 +25,7 @@ export function RegisterForm() {
passwordMin: tv("passwordMin"),
confirmPasswordRequired: tv("confirmPasswordRequired"),
passwordMismatch: tv("passwordMismatch"),
usernameInvalid: tv("usernameInvalid"),
});
const {
@@ -29,12 +34,27 @@ export function RegisterForm() {
formState: { errors, isSubmitting },
} = useForm<RegisterFormValues>({
resolver: zodResolver(schema),
defaultValues: { email: "", password: "", confirmPassword: "" },
defaultValues: { email: "", username: "", displayName: "", password: "", confirmPassword: "" },
});
async function onSubmit(values: RegisterFormValues) {
await submitRegister({ email: values.email, password: values.password });
setSubmitted(true);
setApiError(null);
try {
await submitRegister({
email: values.email,
password: values.password,
...(values.username ? { username: values.username } : {}),
...(values.displayName ? { displayName: values.displayName } : {}),
});
router.push("/verify-email?sent=1");
} catch (error) {
if (error instanceof ApiError) {
setApiError(error.detail ?? error.title);
} else {
setApiError(t("errors.generic"));
}
}
}
return (
@@ -56,7 +76,7 @@ export function RegisterForm() {
placeholder={t("emailPlaceholder")}
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined}
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
className={inputClassName}
{...register("email")}
/>
{errors.email ? (
@@ -66,6 +86,44 @@ export function RegisterForm() {
) : null}
</div>
<div className="space-y-2">
<label htmlFor="username" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("username")}{" "}
<span className="font-normal text-zinc-500">({t("optional")})</span>
</label>
<input
id="username"
type="text"
autoComplete="username"
placeholder={t("usernamePlaceholder")}
aria-invalid={errors.username ? "true" : "false"}
aria-describedby={errors.username ? "username-error" : undefined}
className={inputClassName}
{...register("username")}
/>
{errors.username ? (
<p id="username-error" className="text-sm text-red-600" role="alert">
{errors.username.message}
</p>
) : null}
</div>
<div className="space-y-2">
<label htmlFor="displayName" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("displayName")}{" "}
<span className="font-normal text-zinc-500">({t("optional")})</span>
</label>
<input
id="displayName"
type="text"
autoComplete="name"
placeholder={t("displayNamePlaceholder")}
aria-invalid={errors.displayName ? "true" : "false"}
className={inputClassName}
{...register("displayName")}
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("password")}
@@ -77,7 +135,7 @@ export function RegisterForm() {
placeholder={t("passwordPlaceholder")}
aria-invalid={errors.password ? "true" : "false"}
aria-describedby={errors.password ? "password-error" : undefined}
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
className={inputClassName}
{...register("password")}
/>
{errors.password ? (
@@ -101,7 +159,7 @@ export function RegisterForm() {
placeholder={t("passwordPlaceholder")}
aria-invalid={errors.confirmPassword ? "true" : "false"}
aria-describedby={errors.confirmPassword ? "confirm-password-error" : undefined}
className="flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
className={inputClassName}
{...register("confirmPassword")}
/>
{errors.confirmPassword ? (
@@ -111,9 +169,9 @@ export function RegisterForm() {
) : null}
</div>
{submitted ? (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
{t("apiPending")}
{apiError ? (
<p className="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">
{apiError}
</p>
) : null}

View File

@@ -0,0 +1,138 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Link, useRouter } from "@/i18n/navigation";
import { ApiError, resetPassword } from "@/lib/api/auth";
import { createResetPasswordSchema, type ResetPasswordFormValues } from "@/lib/schemas/auth";
const inputClassName =
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
export function ResetPasswordForm() {
const t = useTranslations("auth");
const tv = useTranslations("validation");
const router = useRouter();
const searchParams = useSearchParams();
const [apiError, setApiError] = useState<string | null>(null);
const token = searchParams.get("token");
const schema = createResetPasswordSchema({
passwordRequired: tv("passwordRequired"),
passwordMin: tv("passwordMin"),
confirmPasswordRequired: tv("confirmPasswordRequired"),
passwordMismatch: tv("passwordMismatch"),
});
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ResetPasswordFormValues>({
resolver: zodResolver(schema),
defaultValues: { password: "", confirmPassword: "" },
});
if (!token) {
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("resetPasswordTitle")}</CardTitle>
<CardDescription>{t("resetPasswordMissingToken")}</CardDescription>
</CardHeader>
<CardContent>
<Link href="/forgot-password" className="text-sm font-medium text-sky-600 hover:underline dark:text-sky-400">
{t("forgotPasswordLink")}
</Link>
</CardContent>
</Card>
);
}
async function onSubmit(values: ResetPasswordFormValues) {
setApiError(null);
try {
await resetPassword(token!, values.password);
router.push("/login");
} catch (error) {
if (error instanceof ApiError) {
setApiError(error.detail ?? error.title);
} else {
setApiError(t("errors.generic"));
}
}
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("resetPasswordTitle")}</CardTitle>
<CardDescription>{t("resetPasswordSubtitle")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("password")}
</label>
<input
id="password"
type="password"
autoComplete="new-password"
placeholder={t("passwordPlaceholder")}
aria-invalid={errors.password ? "true" : "false"}
aria-describedby={errors.password ? "password-error" : undefined}
className={inputClassName}
{...register("password")}
/>
{errors.password ? (
<p id="password-error" className="text-sm text-red-600" role="alert">
{errors.password.message}
</p>
) : null}
</div>
<div className="space-y-2">
<label
htmlFor="confirmPassword"
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
>
{t("confirmPassword")}
</label>
<input
id="confirmPassword"
type="password"
autoComplete="new-password"
placeholder={t("passwordPlaceholder")}
aria-invalid={errors.confirmPassword ? "true" : "false"}
aria-describedby={errors.confirmPassword ? "confirm-password-error" : undefined}
className={inputClassName}
{...register("confirmPassword")}
/>
{errors.confirmPassword ? (
<p id="confirm-password-error" className="text-sm text-red-600" role="alert">
{errors.confirmPassword.message}
</p>
) : null}
</div>
{apiError ? (
<p className="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">
{apiError}
</p>
) : null}
<Button type="submit" className="w-full" isLoading={isSubmitting}>
{t("resetPasswordSubmit")}
</Button>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,171 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { ApiError, confirmTwoFactor, setupTwoFactor, type TwoFactorSetupResponse } from "@/lib/api/auth";
import { createTwoFactorSchema, type TwoFactorFormValues } from "@/lib/schemas/auth";
import { useAuth } from "@/components/providers/auth-provider";
const inputClassName =
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
export function SecurityContent() {
const t = useTranslations("auth");
const tv = useTranslations("validation");
const { user, refresh } = useAuth();
const [setup, setSetup] = useState<TwoFactorSetupResponse | null>(null);
const [apiError, setApiError] = useState<string | null>(null);
const [confirmed, setConfirmed] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const schema = createTwoFactorSchema({
codeRequired: tv("codeRequired"),
codeInvalid: tv("codeInvalid"),
});
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TwoFactorFormValues>({
resolver: zodResolver(schema),
defaultValues: { code: "" },
});
async function handleStartSetup() {
setApiError(null);
setIsStarting(true);
try {
const response = await setupTwoFactor();
setSetup(response);
} catch (error) {
if (error instanceof ApiError) {
setApiError(error.detail ?? error.title);
} else {
setApiError(t("errors.generic"));
}
} finally {
setIsStarting(false);
}
}
async function onSubmit(values: TwoFactorFormValues) {
setApiError(null);
try {
await confirmTwoFactor(values.code);
setConfirmed(true);
setSetup(null);
await refresh();
} catch (error) {
if (error instanceof ApiError) {
setApiError(error.detail ?? error.title);
} else {
setApiError(t("errors.generic"));
}
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{t("securityTitle")}
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("securitySubtitle")}</p>
</div>
<Card>
<CardHeader>
<CardTitle>{t("twoFactorSetupTitle")}</CardTitle>
<CardDescription>
{user?.twoFactorEnabled ? t("twoFactorEnabled") : t("twoFactorSetupDescription")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{user?.twoFactorEnabled || confirmed ? (
<p className="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100">
{t("twoFactorEnabled")}
</p>
) : null}
{!user?.twoFactorEnabled && !confirmed && !setup ? (
<Button onClick={handleStartSetup} isLoading={isStarting}>
{t("twoFactorSetupStart")}
</Button>
) : null}
{setup ? (
<div className="space-y-4">
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("twoFactorScanQr")}</p>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={setup.qrCodeUrl}
alt={t("twoFactorQrAlt")}
className="h-48 w-48 rounded-md border border-zinc-200 dark:border-zinc-800"
/>
<p className="font-mono text-xs text-zinc-500">{setup.secret}</p>
{setup.recoveryCodes?.length ? (
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="mb-2 text-sm font-medium text-amber-900 dark:text-amber-100">
{t("recoveryCodesTitle")}
</p>
<ul className="grid gap-1 font-mono text-xs text-amber-900 dark:text-amber-100 sm:grid-cols-2">
{setup.recoveryCodes.map((code) => (
<li key={code}>{code}</li>
))}
</ul>
</div>
) : null}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div className="space-y-2">
<label htmlFor="code" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{t("twoFactorCode")}
</label>
<input
id="code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="000000"
maxLength={6}
aria-invalid={errors.code ? "true" : "false"}
className={inputClassName}
{...register("code")}
/>
{errors.code ? (
<p className="text-sm text-red-600" role="alert">
{errors.code.message}
</p>
) : null}
</div>
{apiError ? (
<p className="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">
{apiError}
</p>
) : null}
<Button type="submit" isLoading={isSubmitting}>
{t("twoFactorConfirm")}
</Button>
</form>
</div>
) : null}
{apiError && !setup ? (
<p className="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">
{apiError}
</p>
) : null}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,129 @@
"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 } from "@/i18n/navigation";
import { ApiError, verifyEmail } from "@/lib/api/auth";
type VerifyState = "idle" | "loading" | "success" | "error";
export function VerifyEmailContent() {
const t = useTranslations("auth");
const searchParams = useSearchParams();
const token = searchParams.get("token");
const sent = searchParams.get("sent");
const [state, setState] = useState<VerifyState>(token ? "loading" : "idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (!token) {
return;
}
let cancelled = false;
async function verify() {
try {
await verifyEmail(token!);
if (!cancelled) {
setState("success");
}
} catch (error) {
if (!cancelled) {
setState("error");
if (error instanceof ApiError) {
setErrorMessage(error.detail ?? error.title);
} else {
setErrorMessage(t("errors.generic"));
}
}
}
}
void verify();
return () => {
cancelled = true;
};
}, [token, t]);
if (sent === "1") {
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("verifyEmailTitle")}</CardTitle>
<CardDescription>{t("verifyEmailSent")}</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 (!token) {
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("verifyEmailTitle")}</CardTitle>
<CardDescription>{t("verifyEmailMissingToken")}</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") {
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("verifyEmailTitle")}</CardTitle>
<CardDescription>{t("verifyEmailLoading")}</CardDescription>
</CardHeader>
</Card>
);
}
if (state === "success") {
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("verifyEmailTitle")}</CardTitle>
<CardDescription>{t("verifyEmailSuccess")}</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>
);
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t("verifyEmailTitle")}</CardTitle>
<CardDescription>{t("verifyEmailError")}</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

@@ -26,7 +26,6 @@ export function DashboardContent() {
{t("title")}
</h1>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("welcome")}</p>
<p className="mt-2 font-mono text-xs text-zinc-500">{t("protectedNote")}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">

View File

@@ -0,0 +1,48 @@
"use client";
import { Button } from "@hexahost/ui";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { useAuth } from "@/components/providers/auth-provider";
export function DashboardUserInfo() {
const t = useTranslations("common");
const tAuth = useTranslations("auth");
const { user, isLoading, logout } = useAuth();
if (isLoading) {
return (
<p className="font-mono text-xs text-zinc-500">{t("loading")}</p>
);
}
if (!user) {
return (
<p className="font-mono text-xs text-zinc-500">
{tAuth("sessionNone")}
</p>
);
}
const displayLabel = user.displayName ?? user.username;
return (
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:gap-4">
<div className="text-right sm:text-left">
<p className="text-sm font-medium text-zinc-900 dark:text-zinc-50">{displayLabel}</p>
<p className="font-mono text-xs text-zinc-500">{user.email}</p>
</div>
<div className="flex items-center gap-2">
<Link
href="/security"
className="text-xs font-medium text-sky-600 hover:underline dark:text-sky-400"
>
{tAuth("securityTitle")}
</Link>
<Button variant="secondary" size="sm" onClick={() => void logout()}>
{t("logout")}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
getCurrentUser,
submitLogout,
type CurrentUser,
} from "@/lib/api/auth";
import { useRouter } from "@/i18n/navigation";
interface AuthContextValue {
user: CurrentUser | null;
isLoading: boolean;
isAuthenticated: boolean;
refresh: () => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const router = useRouter();
const [user, setUser] = useState<CurrentUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const currentUser = await getCurrentUser();
setUser(currentUser);
} catch {
setUser(null);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const logout = useCallback(async () => {
try {
await submitLogout();
} finally {
setUser(null);
router.push("/login");
}
}, [router]);
const value = useMemo(
() => ({
user,
isLoading,
isAuthenticated: user !== null,
refresh,
logout,
}),
[user, isLoading, refresh, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}

View File

@@ -1,45 +1,211 @@
import { getApiUrl } from "../env";
export interface ProblemDetails {
type: string;
title: string;
status: number;
detail?: string;
instance?: string;
errors?: { field?: string; message: string }[];
}
export class ApiError extends Error {
readonly status: number;
readonly title: string;
readonly detail?: string;
readonly errors?: { field?: string; message: string }[];
constructor(problem: ProblemDetails) {
super(problem.detail ?? problem.title);
this.name = "ApiError";
this.status = problem.status;
this.title = problem.title;
this.detail = problem.detail;
this.errors = problem.errors;
}
}
export interface AuthCredentials {
email: string;
password: string;
}
export interface AuthResponse {
accessToken?: string;
refreshToken?: string;
export interface RegisterPayload {
email: string;
password: string;
username?: string;
displayName?: string;
}
/**
* Prepared API client for Phase 1 authentication.
* Currently validates structure only; no network request is performed.
*/
export async function submitLogin(
credentials: AuthCredentials,
): Promise<AuthResponse> {
const endpoint = `${getApiUrl()}/auth/login`;
export interface LoginResponse {
twoFactorRequired?: boolean;
challengeId?: string;
}
export interface CurrentUser {
id: string;
email: string;
username: string;
displayName?: string | null;
emailVerifiedAt?: string | null;
roles?: string[];
twoFactorEnabled?: boolean;
}
export interface TwoFactorSetupResponse {
secret: string;
qrCodeUrl: string;
recoveryCodes?: string[];
}
export interface UserSession {
id: string;
ipAddress?: string | null;
userAgent?: string | null;
createdAt: string;
expiresAt: string;
current?: boolean;
}
async function parseProblemResponse(response: Response): Promise<ApiError> {
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/problem+json")) {
const problem = (await response.json()) as ProblemDetails;
return new ApiError({
...problem,
status: problem.status ?? response.status,
title: problem.title ?? "Request failed",
});
}
return new ApiError({
type: "about:blank",
title: "Request failed",
status: response.status,
detail: response.statusText || undefined,
});
}
export async function apiFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const endpoint = `${getApiUrl()}${path}`;
if (!endpoint.startsWith("http")) {
throw new Error("Invalid API URL configuration");
}
void credentials;
void endpoint;
const headers = new Headers(options.headers);
return Promise.resolve({});
if (options.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(endpoint, {
...options,
credentials: "include",
headers,
});
if (!response.ok) {
throw await parseProblemResponse(response);
}
if (response.status === 204) {
return undefined as T;
}
const text = await response.text();
if (!text) {
return undefined as T;
}
return JSON.parse(text) as T;
}
export async function submitLogin(
credentials: AuthCredentials,
): Promise<LoginResponse> {
return apiFetch<LoginResponse>("/auth/login", {
method: "POST",
body: JSON.stringify(credentials),
});
}
export async function submitRegister(
credentials: AuthCredentials,
): Promise<AuthResponse> {
const endpoint = `${getApiUrl()}/auth/register`;
if (!endpoint.startsWith("http")) {
throw new Error("Invalid API URL configuration");
}
void credentials;
void endpoint;
return Promise.resolve({});
payload: RegisterPayload,
): Promise<void> {
await apiFetch<void>("/auth/register", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function submitLogout(): Promise<void> {
await apiFetch<void>("/auth/logout", {
method: "POST",
});
}
export async function getCurrentUser(): Promise<CurrentUser> {
return apiFetch<CurrentUser>("/me");
}
export async function verifyEmail(token: string): Promise<void> {
await apiFetch<void>("/auth/verify-email", {
method: "POST",
body: JSON.stringify({ token }),
});
}
export async function forgotPassword(email: string): Promise<void> {
await apiFetch<void>("/auth/forgot-password", {
method: "POST",
body: JSON.stringify({ email }),
});
}
export async function resetPassword(
token: string,
newPassword: string,
): Promise<void> {
await apiFetch<void>("/auth/reset-password", {
method: "POST",
body: JSON.stringify({ token, newPassword }),
});
}
export async function setupTwoFactor(): Promise<TwoFactorSetupResponse> {
return apiFetch<TwoFactorSetupResponse>("/auth/2fa/setup", {
method: "POST",
});
}
export async function confirmTwoFactor(code: string): Promise<void> {
await apiFetch<void>("/auth/2fa/confirm", {
method: "POST",
body: JSON.stringify({ code }),
});
}
export async function verifyTwoFactorLogin(
challengeId: string,
code: string,
): Promise<void> {
await apiFetch<void>("/auth/2fa/verify", {
method: "POST",
body: JSON.stringify({ challengeId, code }),
});
}
export async function getSessions(): Promise<UserSession[]> {
return apiFetch<UserSession[]>("/me/sessions");
}
export async function revokeSession(sessionId: string): Promise<void> {
await apiFetch<void>(`/me/sessions/${sessionId}`, {
method: "DELETE",
});
}

View File

@@ -25,6 +25,7 @@ export function createRegisterSchema(messages: {
passwordMin: string;
confirmPasswordRequired: string;
passwordMismatch: string;
usernameInvalid?: string;
}) {
return z
.object({
@@ -32,6 +33,13 @@ export function createRegisterSchema(messages: {
.string()
.min(1, messages.emailRequired)
.email(messages.emailInvalid),
username: z
.string()
.refine((val) => val === "" || /^[a-zA-Z0-9_-]{3,32}$/.test(val), {
message: messages.usernameInvalid ?? "Invalid username",
})
.default(""),
displayName: z.string().max(64).default(""),
password: z
.string()
.min(1, messages.passwordRequired)
@@ -44,5 +52,56 @@ export function createRegisterSchema(messages: {
});
}
export function createForgotPasswordSchema(messages: {
emailRequired: string;
emailInvalid: string;
}) {
return z.object({
email: z
.string()
.min(1, messages.emailRequired)
.email(messages.emailInvalid),
});
}
export function createResetPasswordSchema(messages: {
passwordRequired: string;
passwordMin: string;
confirmPasswordRequired: string;
passwordMismatch: string;
}) {
return z
.object({
password: z
.string()
.min(1, messages.passwordRequired)
.min(8, messages.passwordMin),
confirmPassword: z.string().min(1, messages.confirmPasswordRequired),
})
.refine((data) => data.password === data.confirmPassword, {
message: messages.passwordMismatch,
path: ["confirmPassword"],
});
}
export function createTwoFactorSchema(messages: {
codeRequired: string;
codeInvalid: string;
}) {
return z.object({
code: z
.string()
.min(1, messages.codeRequired)
.regex(/^\d{6}$/, messages.codeInvalid),
});
}
export type LoginFormValues = z.infer<ReturnType<typeof createLoginSchema>>;
export type RegisterFormValues = z.infer<ReturnType<typeof createRegisterSchema>>;
export type ForgotPasswordFormValues = z.infer<
ReturnType<typeof createForgotPasswordSchema>
>;
export type ResetPasswordFormValues = z.infer<
ReturnType<typeof createResetPasswordSchema>
>;
export type TwoFactorFormValues = z.infer<ReturnType<typeof createTwoFactorSchema>>;

View File

@@ -1,7 +1,50 @@
import createMiddleware from "next-intl/middleware";
import { type NextRequest, NextResponse } from "next/server";
import { routing } from "./i18n/routing";
export default createMiddleware(routing);
const intlMiddleware = createMiddleware(routing);
const protectedPaths = ["/dashboard", "/security"];
function getPathWithoutLocale(pathname: string): string {
if (pathname === "/en" || pathname.startsWith("/en/")) {
return pathname.slice(3) || "/";
}
return pathname;
}
function buildLocalizedPath(path: string, pathname: string): string {
if (pathname === "/en" || pathname.startsWith("/en/")) {
return `/en${path}`;
}
return path;
}
export default function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const pathWithoutLocale = getPathWithoutLocale(pathname);
const isProtected = protectedPaths.some(
(protectedPath) =>
pathWithoutLocale === protectedPath ||
pathWithoutLocale.startsWith(`${protectedPath}/`),
);
if (isProtected) {
const session = request.cookies.get("hgc_session");
if (!session?.value) {
const loginUrl = request.nextUrl.clone();
loginUrl.pathname = buildLocalizedPath("/login", pathname);
loginUrl.searchParams.set("redirect", pathname);
return NextResponse.redirect(loginUrl);
}
}
return intlMiddleware(request);
}
export const config = {
matcher: ["/", "/(de|en)/:path*", "/((?!_next|_vercel|.*\\..*).*)"],

File diff suppressed because one or more lines are too long