Phase 3. 인증 및 공통 레이아웃 작업
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
ClipboardList,
|
||||
GraduationCap,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
} from "lucide-react";
|
||||
|
||||
import { auth, signOut } from "@/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "대시보드", icon: LayoutDashboard },
|
||||
{ href: "/maestros", label: "마에스트로", icon: GraduationCap },
|
||||
{ href: "/extension-requests", label: "연장 신청", icon: ClipboardList },
|
||||
{ href: "/upgrade-requests", label: "업그레이드", icon: ArrowUpCircle },
|
||||
];
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/40">
|
||||
<aside className="fixed inset-y-0 left-0 hidden w-64 border-r bg-background md:block">
|
||||
<div className="flex h-16 items-center border-b px-5">
|
||||
<Link className="text-lg font-semibold" href="/">
|
||||
choco-admin
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="space-y-1 p-3">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
href={item.href}
|
||||
key={item.href}
|
||||
>
|
||||
<Icon className="size-4" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="md:pl-64">
|
||||
<header className="sticky top-0 z-10 flex h-16 items-center justify-between border-b bg-background px-5">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{session.user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
관리자 ID {session.user.adminID}
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}}
|
||||
>
|
||||
<Button size="sm" type="submit" variant="outline">
|
||||
<LogOut className="size-4" aria-hidden="true" />
|
||||
로그아웃
|
||||
</Button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<main className="p-5">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Database, ShieldCheck, Table2 } from "lucide-react";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
|
||||
const checks = [
|
||||
{
|
||||
label: "Next.js 16",
|
||||
value: "App Router",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
label: "Prisma",
|
||||
value: "MariaDB introspected",
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
label: "TanStack Table",
|
||||
value: "Installed",
|
||||
icon: Table2,
|
||||
},
|
||||
];
|
||||
|
||||
export default async function Home() {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">대시보드</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{session?.user.name} 관리자 계정으로 로그인했습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{checks.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<article
|
||||
className="rounded-lg border bg-card p-5 text-card-foreground"
|
||||
key={item.label}
|
||||
>
|
||||
<div className="mb-4 flex size-9 items-center justify-center rounded-md bg-muted">
|
||||
<Icon className="size-5" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="text-base font-medium">{item.label}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{item.value}</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use server";
|
||||
|
||||
import { AuthError } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { signIn } from "@/auth";
|
||||
|
||||
export async function loginAction(formData: FormData) {
|
||||
const callbackUrl = String(formData.get("callbackUrl") ?? "/");
|
||||
|
||||
try {
|
||||
await signIn("credentials", {
|
||||
adminName: formData.get("adminName"),
|
||||
password: formData.get("password"),
|
||||
redirectTo: callbackUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
redirect(
|
||||
`/login?error=CredentialsSignin&callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { loginAction } from "./actions";
|
||||
|
||||
type LoginPageProps = {
|
||||
searchParams: Promise<{
|
||||
callbackUrl?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
const session = await auth();
|
||||
|
||||
if (session) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const params = await searchParams;
|
||||
const callbackUrl = params.callbackUrl ?? "/";
|
||||
const hasError = params.error === "CredentialsSignin";
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-muted px-6 py-10">
|
||||
<section className="w-full max-w-md rounded-lg border bg-background p-8 shadow-sm">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold tracking-normal">
|
||||
choco-admin
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">관리자 로그인</p>
|
||||
</div>
|
||||
|
||||
{hasError ? (
|
||||
<div className="mb-5 rounded-md border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
관리자 아이디 또는 암호가 올바르지 않습니다.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form action={loginAction} className="space-y-5">
|
||||
<input name="callbackUrl" type="hidden" value={callbackUrl} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="adminName">
|
||||
관리자 아이디
|
||||
</label>
|
||||
<input
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
id="adminName"
|
||||
name="adminName"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="password">
|
||||
암호
|
||||
</label>
|
||||
<input
|
||||
autoComplete="current-password"
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-3 focus:ring-ring/30"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button className="w-full" type="submit">
|
||||
로그인
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { GET, POST } from "@/auth";
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Database, ShieldCheck, Table2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const checks = [
|
||||
{
|
||||
label: "Next.js 16",
|
||||
value: "App Router",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
label: "Prisma",
|
||||
value: "MariaDB introspected",
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
label: "TanStack Table",
|
||||
value: "Installed",
|
||||
icon: Table2,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="min-h-screen bg-background text-foreground">
|
||||
<section className="mx-auto flex min-h-screen w-full max-w-6xl flex-col gap-8 px-6 py-8">
|
||||
<header className="flex items-center justify-between border-b pb-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">
|
||||
choco-admin
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Phase 1 initialization
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button">Ready</Button>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{checks.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<article
|
||||
className="rounded-lg border bg-card p-5 text-card-foreground"
|
||||
key={item.label}
|
||||
>
|
||||
<div className="mb-4 flex size-9 items-center justify-center rounded-md bg-muted">
|
||||
<Icon className="size-5" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="text-base font-medium">{item.label}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{item.value}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user