Initial fullstack project setup with Next.js 15, Gin, PostgreSQL and Docker Compose

- Frontend: Next.js 15 (App Router), Auth.js v5, shadcn/ui, MagicUI
- Backend: Go + Gin + GORM with layered architecture
- Auth: Local credentials login with optional Keycloak OAuth binding
- Admin: RBAC user management for admin role
- Dev: Docker Compose with hot reload for both frontend and backend
- Docker: 3-service orchestration (frontend, backend, postgres)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 15:11:20 +00:00
commit b0b85f4d3a
62 changed files with 12113 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface User {
id: number;
username: string;
email: string;
role: string;
createdAt: string;
}
export default function AdminPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(false);
const [form, setForm] = useState({
username: "",
email: "",
password: "",
role: "user",
});
async function fetchUsers() {
const res = await fetch("/api/proxy/admin/users");
if (res.ok) {
const data = await res.json();
setUsers(data.users || []);
}
setLoading(false);
}
useEffect(() => {
fetchUsers();
}, []);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const res = await fetch("/api/proxy/admin/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (res.ok) {
setOpen(false);
setForm({ username: "", email: "", password: "", role: "user" });
fetchUsers();
}
}
async function handleDelete(id: number) {
if (!confirm("确定删除该用户?")) return;
const res = await fetch(`/api/proxy/admin/users/${id}`, {
method: "DELETE",
});
if (res.ok) {
fetchUsers();
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button></Button>} />
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<Label></Label>
<Input
value={form.username}
onChange={(e) =>
setForm({ ...form, username: e.target.value })
}
required
/>
</div>
<div>
<Label></Label>
<Input
type="email"
value={form.email}
onChange={(e) =>
setForm({ ...form, email: e.target.value })
}
required
/>
</div>
<div>
<Label></Label>
<Input
type="password"
value={form.password}
onChange={(e) =>
setForm({ ...form, password: e.target.value })
}
required
/>
</div>
<div>
<Label></Label>
<select
className="w-full rounded-md border px-3 py-2 text-sm"
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value })}
>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</div>
<Button type="submit" className="w-full">
</Button>
</form>
</DialogContent>
</Dialog>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<p>...</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.id}</TableCell>
<TableCell>{user.username}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.role}</TableCell>
<TableCell>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(user.id)}
>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { auth } from "@/auth";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default async function DashboardPage() {
const session = await auth();
const user = session?.user as any;
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">{user?.name || user?.email}</h1>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<p>
<span className="font-medium"></span>
{user?.name}
</p>
<p>
<span className="font-medium"></span>
{user?.email}
</p>
<p>
<span className="font-medium"></span>
{user?.role}
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import Link from "next/link";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { signOut } from "@/auth";
export default async function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
const user = session?.user as any;
return (
<div className="min-h-screen bg-gray-50">
<header className="border-b bg-white">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
<Link href="/" className="text-lg font-bold">
EvanPage
</Link>
<nav className="flex items-center gap-4">
<Link href="/dashboard" className="text-sm hover:underline">
</Link>
{user?.role === "admin" && (
<Link href="/admin" className="text-sm hover:underline">
</Link>
)}
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/login" });
}}
>
<Button variant="ghost" size="sm" type="submit">
退
</Button>
</form>
</nav>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-6">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,3 @@
import { GET, POST } from "@/auth";
export { GET, POST };

View File

@@ -0,0 +1,48 @@
import { auth } from "@/auth";
import { NextRequest, NextResponse } from "next/server";
const BACKEND_URL = process.env.SERVER_API_URL || "http://backend:8080";
async function handler(
req: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const session = await auth();
const { path } = await params;
const pathStr = path?.join("/") || "";
const url = `${BACKEND_URL}/api/${pathStr}${req.nextUrl.search}`;
const headers = new Headers(req.headers);
headers.delete("host");
if (session?.user) {
headers.set("X-User-Id", (session.user as any).id || "");
headers.set("X-User-Role", (session.user as any).role || "");
}
const body =
req.method === "GET" || req.method === "HEAD"
? undefined
: await req.arrayBuffer();
const res = await fetch(url, {
method: req.method,
headers,
body,
});
const data = await res.arrayBuffer();
return new NextResponse(data, {
status: res.status,
statusText: res.statusText,
headers: res.headers,
});
}
export const GET = handler;
export const POST = handler;
export const PUT = handler;
export const DELETE = handler;
export const PATCH = handler;
export const OPTIONS = handler;

View File

@@ -0,0 +1,97 @@
"use client";
import { Suspense } from "react";
import { useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
function BindForm() {
const searchParams = useSearchParams();
const router = useRouter();
const keycloakId = searchParams.get("keycloakId") || "";
const keycloakEmail = searchParams.get("email") || "";
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
const res = await fetch("/api/proxy/auth/bind-keycloak", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username,
password,
keycloakId,
keycloakEmail,
}),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || "绑定失败");
return;
}
const data = await res.json();
if (data.bound) {
await signIn("keycloak", { callbackUrl: "/dashboard" });
}
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-gray-500">
Keycloak {keycloakEmail || keycloakId}
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="username"></Label>
<Input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full">
</Button>
</form>
</CardContent>
</Card>
);
}
export default function BindAccountPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
<Suspense fallback={<div>...</div>}>
<BindForm />
</Suspense>
</div>
);
}

BIN
frontend/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

130
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

151
frontend/app/init/page.tsx Normal file
View File

@@ -0,0 +1,151 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function InitPage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [initialized, setInitialized] = useState(true);
const [form, setForm] = useState({
username: "",
email: "",
password: "",
confirmPassword: "",
});
const [error, setError] = useState("");
useEffect(() => {
fetch("/api/proxy/admin/users", {
headers: { "X-User-Role": "admin" },
})
.then((res) => {
if (res.ok) {
setInitialized(true);
} else {
setInitialized(false);
}
})
.catch(() => setInitialized(false))
.finally(() => setLoading(false));
}, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (form.password !== form.confirmPassword) {
setError("两次输入的密码不一致");
return;
}
const res = await fetch("/api/proxy/auth/init", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: form.username,
email: form.email,
password: form.password,
}),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || "初始化失败");
return;
}
router.push("/login");
}
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center">
...
</div>
);
}
if (initialized) {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center"></CardTitle>
</CardHeader>
<CardContent>
<p className="text-center text-gray-500">
</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-gray-500">
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="username"></Label>
<Input
id="username"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
required
/>
</div>
<div>
<Label htmlFor="email"></Label>
<Input
id="email"
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
required
/>
</div>
<div>
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
required
/>
</div>
<div>
<Label htmlFor="confirmPassword"></Label>
<Input
id="confirmPassword"
type="password"
value={form.confirmPassword}
onChange={(e) =>
setForm({ ...form, confirmPassword: e.target.value })
}
required
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full">
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

33
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}

View File

@@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function LoginForm({ hasKeycloak }: { hasKeycloak: boolean }) {
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") || "/dashboard";
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
const res = await signIn("credentials", {
username,
password,
redirect: false,
callbackUrl,
});
if (res?.error) {
setError("登录失败,请检查用户名和密码");
} else {
const redirectUrl = callbackUrl.startsWith("http")
? callbackUrl
: window.location.origin + callbackUrl;
window.location.href = redirectUrl;
}
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="username"></Label>
<Input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full">
</Button>
</form>
{hasKeycloak && (
<>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-gray-500"></span>
</div>
</div>
<Button
variant="outline"
className="w-full"
onClick={() => signIn("keycloak", { callbackUrl })}
>
Keycloak
</Button>
</>
)}
<p className="text-center text-sm text-gray-500">
{" "}
<a href="/register" className="text-blue-600 hover:underline">
</a>
</p>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,14 @@
import { Suspense } from "react";
import { LoginForm } from "./login-form";
const hasKeycloak = !!process.env.AUTH_KEYCLOAK_ISSUER;
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
<Suspense fallback={<div>...</div>}>
<LoginForm hasKeycloak={hasKeycloak} />
</Suspense>
</div>
);
}

64
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,64 @@
import { BlurFade } from "@/components/magicui/blur-fade";
const SERVER_API_URL = process.env.SERVER_API_URL || "http://backend:8080";
export default async function HomePage() {
let healthText = "无法连接到后端服务";
try {
const res = await fetch(`${SERVER_API_URL}/api/health`, {
cache: "no-store",
});
if (res.ok) {
healthText = await res.text();
} else {
healthText = `后端异常: ${res.status}`;
}
} catch (err) {
healthText = "后端连接失败";
}
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4">
<BlurFade inView delay={0.1}>
<h1 className="mb-4 text-center text-4xl font-extrabold tracking-tight text-slate-900">
EvanPage
</h1>
</BlurFade>
<BlurFade inView delay={0.2}>
<p className="mb-8 max-w-md text-center text-lg text-slate-600">
</p>
</BlurFade>
<BlurFade inView delay={0.3}>
<div className="rounded-2xl border bg-white/80 px-8 py-6 shadow-lg backdrop-blur">
<p className="text-center text-sm font-medium text-slate-500">
</p>
<p className="mt-2 text-center text-xl font-semibold text-slate-800">
{healthText}
</p>
</div>
</BlurFade>
<BlurFade inView delay={0.4}>
<div className="mt-8 flex gap-4">
<a
href="/login"
className="rounded-lg bg-slate-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-slate-800"
>
</a>
<a
href="/register"
className="rounded-lg border border-slate-300 bg-white px-5 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
</a>
</div>
</BlurFade>
</div>
);
}

View File

@@ -0,0 +1,113 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export default function RegisterPage() {
const router = useRouter();
const [form, setForm] = useState({
username: "",
email: "",
password: "",
confirmPassword: "",
});
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (form.password !== form.confirmPassword) {
setError("两次输入的密码不一致");
return;
}
const res = await fetch("/api/proxy/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: form.username,
email: form.email,
password: form.password,
}),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || "注册失败");
return;
}
router.push("/login");
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-center"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="username"></Label>
<Input
id="username"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
required
/>
</div>
<div>
<Label htmlFor="email"></Label>
<Input
id="email"
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
required
/>
</div>
<div>
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
required
/>
</div>
<div>
<Label htmlFor="confirmPassword"></Label>
<Input
id="confirmPassword"
type="password"
value={form.confirmPassword}
onChange={(e) =>
setForm({ ...form, confirmPassword: e.target.value })
}
required
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<Button type="submit" className="w-full">
</Button>
</form>
<p className="text-center text-sm text-gray-500">
{" "}
<a href="/login" className="text-blue-600 hover:underline">
</a>
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,11 @@
export default function UnauthorizedPage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4">
<h1 className="text-3xl font-bold">403</h1>
<p className="mt-2 text-gray-500">访</p>
<a href="/dashboard" className="mt-4 text-blue-600 hover:underline">
</a>
</div>
);
}