Phase1
This commit is contained in:
@@ -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>
|
||||
|
||||
20
apps/web/src/app/[locale]/forgot-password/page.tsx
Normal file
20
apps/web/src/app/[locale]/forgot-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
20
apps/web/src/app/[locale]/login/2fa/page.tsx
Normal file
20
apps/web/src/app/[locale]/login/2fa/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
20
apps/web/src/app/[locale]/reset-password/page.tsx
Normal file
20
apps/web/src/app/[locale]/reset-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
apps/web/src/app/[locale]/security/page.tsx
Normal file
17
apps/web/src/app/[locale]/security/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
apps/web/src/app/[locale]/verify-email/page.tsx
Normal file
20
apps/web/src/app/[locale]/verify-email/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
apps/web/src/components/auth/forgot-password-form.tsx
Normal file
114
apps/web/src/components/auth/forgot-password-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
apps/web/src/components/auth/login-2fa-form.tsx
Normal file
123
apps/web/src/components/auth/login-2fa-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
138
apps/web/src/components/auth/reset-password-form.tsx
Normal file
138
apps/web/src/components/auth/reset-password-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
apps/web/src/components/auth/security-content.tsx
Normal file
171
apps/web/src/components/auth/security-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
apps/web/src/components/auth/verify-email-content.tsx
Normal file
129
apps/web/src/components/auth/verify-email-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
48
apps/web/src/components/dashboard/dashboard-user-info.tsx
Normal file
48
apps/web/src/components/dashboard/dashboard-user-info.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
apps/web/src/components/providers/auth-provider.tsx
Normal file
84
apps/web/src/components/providers/auth-provider.tsx
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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|.*\\..*).*)"],
|
||||
|
||||
Reference in New Issue
Block a user