Import source: src/features/auth/auth-screen
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -0,0 +1,367 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Eye, EyeOff, KeyRound, LockKeyhole, Server, ShieldCheck, Wifi } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Logo } from "@/components/common/logo";
|
||||||
|
import { AmbientBackground } from "@/components/shell/ambient-background";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { panelService } from "@/api/services/panel-service";
|
||||||
|
|
||||||
|
const authHighlights = [
|
||||||
|
{ icon: ShieldCheck, label: "Secure authentication flows" },
|
||||||
|
{ icon: KeyRound, label: "Typed frontend API contracts" },
|
||||||
|
{ icon: Server, label: "Backend-ready service wiring" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email("Enter a valid email address."),
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters."),
|
||||||
|
remember: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LoginValues = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
|
export type AuthMode =
|
||||||
|
| "login"
|
||||||
|
| "logout"
|
||||||
|
| "forgot-password"
|
||||||
|
| "reset-password"
|
||||||
|
| "two-factor"
|
||||||
|
| "recovery-code"
|
||||||
|
| "passkey"
|
||||||
|
| "device-verification"
|
||||||
|
| "session-expired"
|
||||||
|
| "account-locked"
|
||||||
|
| "invitation"
|
||||||
|
| "setup";
|
||||||
|
|
||||||
|
export function AuthScreen({ mode }: { mode: AuthMode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [mockError, setMockError] = useState("");
|
||||||
|
const form = useForm<LoginValues>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
defaultValues: { email: "owner@example.com", password: "", remember: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onLogin(values: LoginValues) {
|
||||||
|
setLoading(true);
|
||||||
|
setMockError("");
|
||||||
|
const response = await panelService.auth
|
||||||
|
.login(values.email, values.password)
|
||||||
|
.catch((error: unknown): Awaited<ReturnType<typeof panelService.auth.login>> => {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "The backend API is unreachable.";
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
meta: { requestId: "client_network_error" },
|
||||||
|
error: {
|
||||||
|
code: "NETWORK_ERROR",
|
||||||
|
message,
|
||||||
|
fields: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (response.error) {
|
||||||
|
setMockError(response.error.message);
|
||||||
|
for (const [field, message] of Object.entries(response.error.fields ?? {})) {
|
||||||
|
if (field === "email" || field === "password") {
|
||||||
|
form.setError(field, { message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.data?.challenge === "totp") {
|
||||||
|
router.push("/two-factor");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (values.email.includes("locked")) {
|
||||||
|
router.push("/account-locked");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (values.password.toLowerCase().includes("error")) {
|
||||||
|
setMockError("The supplied credentials were rejected.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode !== "login") return <SecondaryAuthScreen mode={mode} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout>
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<Logo className="mb-3 h-10 w-40" />
|
||||||
|
<CardTitle>Sign in to CoaxysPanel</CardTitle>
|
||||||
|
<CardDescription>Server Hosting Control Center</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="mb-4 grid gap-2 rounded-md border border-border/60 bg-muted/40 p-3 text-xs">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Server className="h-4 w-4" /> srv-dal-01.coaxys.net
|
||||||
|
</span>
|
||||||
|
<Badge variant="success">Online</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Wifi className="h-4 w-4" /> Secure connection verified
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form className="space-y-4" onSubmit={form.handleSubmit(onLogin)}>
|
||||||
|
<label className="block space-y-1 text-sm">
|
||||||
|
<span className="font-medium">Email</span>
|
||||||
|
<Input autoComplete="username" {...form.register("email")} />
|
||||||
|
{form.formState.errors.email ? (
|
||||||
|
<span className="text-xs text-destructive">
|
||||||
|
{form.formState.errors.email.message}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1 text-sm">
|
||||||
|
<span className="font-medium">Password</span>
|
||||||
|
<span className="relative block">
|
||||||
|
<Input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
autoComplete="current-password"
|
||||||
|
{...form.register("password")}
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-2 top-2 text-muted-foreground"
|
||||||
|
onClick={() => setShowPassword((value) => !value)}
|
||||||
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{form.formState.errors.password ? (
|
||||||
|
<span className="text-xs text-destructive">
|
||||||
|
{form.formState.errors.password.message}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" {...form.register("remember")} />
|
||||||
|
Remember this device
|
||||||
|
</label>
|
||||||
|
<Link href="/forgot-password" className="text-primary hover:underline">
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{mockError ? (
|
||||||
|
<div className="anim-rise rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||||
|
{mockError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Button className="w-full" disabled={loading}>
|
||||||
|
{loading ? "Signing in..." : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button type="button" asChild variant="outline">
|
||||||
|
<Link href="/passkey">
|
||||||
|
<KeyRound className="h-4 w-4" /> Passkey
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button type="button" asChild variant="outline">
|
||||||
|
<Link href="/two-factor">
|
||||||
|
<LockKeyhole className="h-4 w-4" /> 2FA
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecondaryAuthScreen({ mode }: { mode: Exclude<AuthMode, "login"> }) {
|
||||||
|
const content = authCopy[mode];
|
||||||
|
return (
|
||||||
|
<AuthLayout>
|
||||||
|
<Card className="w-full max-w-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<Logo className="mb-3 h-10 w-40" />
|
||||||
|
<CardTitle>{content.title}</CardTitle>
|
||||||
|
<CardDescription>{content.description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="rounded-md border border-border/60 bg-muted/40 p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
|
||||||
|
<ShieldCheck className="h-4 w-4 text-primary" />
|
||||||
|
{content.status}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{content.fields.map((field) => (
|
||||||
|
<label key={field} className="space-y-1 text-sm">
|
||||||
|
<span className="font-medium">{field}</span>
|
||||||
|
<Input
|
||||||
|
placeholder={field}
|
||||||
|
type={field.toLowerCase().includes("password") ? "password" : "text"}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button asChild variant="outline">
|
||||||
|
<Link href="/login">Back to login</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={mode === "logout" ? "/login" : "/dashboard"}>{content.action}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<main className="relative grid min-h-screen grid-cols-1 lg:grid-cols-[1fr_520px]">
|
||||||
|
<AmbientBackground />
|
||||||
|
<section className="relative hidden overflow-hidden p-10 lg:flex lg:flex-col lg:justify-between xl:p-14">
|
||||||
|
<Image
|
||||||
|
src="/art/auth-glass.jpg"
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
sizes="(min-width: 1024px) 60vw, 0px"
|
||||||
|
className="hidden object-cover object-center opacity-45 dark:block"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 hidden bg-gradient-to-r from-background/40 via-transparent to-background dark:block"
|
||||||
|
/>
|
||||||
|
<div className="anim-rise relative">
|
||||||
|
<Logo className="h-12 w-48" />
|
||||||
|
<h1 className="text-gradient mt-14 max-w-xl font-display text-5xl font-semibold leading-[1.05] tracking-tight xl:text-6xl">
|
||||||
|
CoaxysPanel
|
||||||
|
</h1>
|
||||||
|
<p className="mt-5 max-w-xl text-lg text-muted-foreground">
|
||||||
|
Dense, secure hosting administration for websites, domains, files, databases, mail,
|
||||||
|
automation, and server operations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="stagger relative grid max-w-xl gap-3 text-sm">
|
||||||
|
{authHighlights.map(({ icon: Icon, label }) => (
|
||||||
|
<div key={label} className="glass-panel flex items-center gap-3 rounded-lg p-3.5">
|
||||||
|
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="anim-rise flex items-center justify-center p-4 sm:p-6">
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authCopy: Record<
|
||||||
|
Exclude<AuthMode, "login">,
|
||||||
|
{ title: string; description: string; status: string; fields: string[]; action: string }
|
||||||
|
> = {
|
||||||
|
logout: {
|
||||||
|
title: "You are signed out",
|
||||||
|
description: "The mock session was closed locally.",
|
||||||
|
status: "Session token cleared in the frontend state.",
|
||||||
|
fields: [],
|
||||||
|
action: "Return to login",
|
||||||
|
},
|
||||||
|
"forgot-password": {
|
||||||
|
title: "Forgot password",
|
||||||
|
description: "Request a reset link for your CoaxysPanel account.",
|
||||||
|
status: "A mock reset email will be queued.",
|
||||||
|
fields: ["Email address"],
|
||||||
|
action: "Send reset link",
|
||||||
|
},
|
||||||
|
"reset-password": {
|
||||||
|
title: "Reset password",
|
||||||
|
description: "Choose a new password after validating the reset token.",
|
||||||
|
status: "Password policy and strength validation are active.",
|
||||||
|
fields: ["Reset token", "New password", "Confirm password"],
|
||||||
|
action: "Reset password",
|
||||||
|
},
|
||||||
|
"two-factor": {
|
||||||
|
title: "Two-factor authentication",
|
||||||
|
description: "Enter the six-digit code from your authenticator app.",
|
||||||
|
status: "TOTP challenge required for this mock account.",
|
||||||
|
fields: ["Authentication code"],
|
||||||
|
action: "Verify code",
|
||||||
|
},
|
||||||
|
"recovery-code": {
|
||||||
|
title: "Recovery code challenge",
|
||||||
|
description: "Use a saved recovery code when the authenticator is unavailable.",
|
||||||
|
status: "Recovery-code validation is simulated.",
|
||||||
|
fields: ["Recovery code"],
|
||||||
|
action: "Verify recovery code",
|
||||||
|
},
|
||||||
|
passkey: {
|
||||||
|
title: "Passkey authentication",
|
||||||
|
description: "Use a passkey registered with this account.",
|
||||||
|
status: "Browser passkey ceremony is represented as a mock flow.",
|
||||||
|
fields: ["Account email"],
|
||||||
|
action: "Use passkey",
|
||||||
|
},
|
||||||
|
"device-verification": {
|
||||||
|
title: "Device verification",
|
||||||
|
description: "Confirm this device before opening a privileged session.",
|
||||||
|
status: "Verification code sent to the mock contact method.",
|
||||||
|
fields: ["Verification code"],
|
||||||
|
action: "Trust device",
|
||||||
|
},
|
||||||
|
"session-expired": {
|
||||||
|
title: "Session expired",
|
||||||
|
description: "Your session reached the configured timeout.",
|
||||||
|
status: "Re-authentication is required.",
|
||||||
|
fields: ["Password"],
|
||||||
|
action: "Resume session",
|
||||||
|
},
|
||||||
|
"account-locked": {
|
||||||
|
title: "Account locked",
|
||||||
|
description: "Too many failed login attempts were detected.",
|
||||||
|
status: "A support operator can unlock the account after verification.",
|
||||||
|
fields: ["Unlock request note"],
|
||||||
|
action: "Request unlock",
|
||||||
|
},
|
||||||
|
invitation: {
|
||||||
|
title: "Accept invitation",
|
||||||
|
description: "Join a CoaxysPanel team with the assigned role.",
|
||||||
|
status: "Invitation token and role preview are ready.",
|
||||||
|
fields: ["Invitation token", "Display name", "Password"],
|
||||||
|
action: "Accept invitation",
|
||||||
|
},
|
||||||
|
setup: {
|
||||||
|
title: "First-run setup",
|
||||||
|
description:
|
||||||
|
"Configure the first server, account, security policy, branding, and notifications.",
|
||||||
|
status: "Setup wizard writes only to mock frontend state.",
|
||||||
|
fields: ["Server hostname", "Admin email", "Primary domain", "Timezone"],
|
||||||
|
action: "Finish setup",
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user