feat: Implement username/password authentication and registration features

This commit is contained in:
beilunyang
2025-01-15 15:48:26 +08:00
parent 969d0ce334
commit 126a4cb948
24 changed files with 2643 additions and 67 deletions

View File

@@ -0,0 +1,27 @@
import { register } from "@/lib/auth"
import { authSchema } from "@/lib/validation"
import { ZodError } from "zod"
export const runtime = "edge"
export async function POST(request: Request) {
try {
const json = await request.json()
const body = authSchema.parse(json)
await register(body.username, body.password)
return Response.json({ success: true })
} catch (error) {
if (error instanceof ZodError) {
return Response.json(
{ error: error.errors[0].message },
{ status: 400 }
)
}
return Response.json(
{ error: error instanceof Error ? error.message : "注册失败" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,342 @@
"use client"
import { useState } from "react"
import { signIn } from "next-auth/react"
import { useToast } from "@/components/ui/use-toast"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs"
import { Github, Loader2, KeyRound, User2 } from "lucide-react"
import { cn } from "@/lib/utils"
interface FormErrors {
username?: string
password?: string
confirmPassword?: string
}
export function LoginForm() {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [loading, setLoading] = useState(false)
const [errors, setErrors] = useState<FormErrors>({})
const { toast } = useToast()
const validateLoginForm = () => {
const newErrors: FormErrors = {}
if (!username) newErrors.username = "请输入用户名"
if (!password) newErrors.password = "请输入密码"
if (password && password.length < 8) newErrors.password = "密码长度必须大于等于8位"
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const validateRegisterForm = () => {
const newErrors: FormErrors = {}
if (!username) newErrors.username = "请输入用户名"
if (!password) newErrors.password = "请输入密码"
if (password && password.length < 8) newErrors.password = "密码长度必须大于等于8位"
if (!confirmPassword) newErrors.confirmPassword = "请确认密码"
if (password !== confirmPassword) newErrors.confirmPassword = "两次输入的密码不一致"
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleLogin = async () => {
if (!validateLoginForm()) return
setLoading(true)
try {
const result = await signIn("credentials", {
username,
password,
redirect: false,
})
if (result?.error) {
toast({
title: "登录失败",
description: "用户名或密码错误",
variant: "destructive",
})
setLoading(false)
return
}
window.location.href = "/"
} catch (error) {
toast({
title: "登录失败",
description: error instanceof Error ? error.message : "请稍后重试",
variant: "destructive",
})
setLoading(false)
}
}
const handleRegister = async () => {
if (!validateRegisterForm()) return
setLoading(true)
try {
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
})
const data = await response.json() as { error?: string }
if (!response.ok) {
toast({
title: "注册失败",
description: data.error || "请稍后重试",
variant: "destructive",
})
setLoading(false)
return
}
// 注册成功后自动登录
const result = await signIn("credentials", {
username,
password,
redirect: false,
})
if (result?.error) {
toast({
title: "登录失败",
description: "自动登录失败,请手动登录",
variant: "destructive",
})
setLoading(false)
return
}
window.location.href = "/"
} catch (error) {
toast({
title: "注册失败",
description: error instanceof Error ? error.message : "请稍后重试",
variant: "destructive",
})
setLoading(false)
}
}
const handleGithubLogin = () => {
signIn("github", { callbackUrl: "/" })
}
const clearForm = () => {
setUsername("")
setPassword("")
setConfirmPassword("")
setErrors({})
}
return (
<Card className="w-[95%] max-w-lg border-2 border-primary/20">
<CardHeader className="space-y-2">
<CardTitle className="text-2xl text-center bg-gradient-to-r from-primary to-purple-600 bg-clip-text text-transparent">
使 MoeMail
</CardTitle>
<CardDescription className="text-center">
()
</CardDescription>
</CardHeader>
<CardContent className="px-6">
<Tabs defaultValue="login" className="w-full" onValueChange={clearForm}>
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="login"></TabsTrigger>
<TabsTrigger value="register"></TabsTrigger>
</TabsList>
<div className="min-h-[220px]">
<TabsContent value="login" className="space-y-4 mt-0">
<div className="space-y-3">
<div className="space-y-1.5">
<div className="relative">
<div className="absolute left-2.5 top-2 text-muted-foreground">
<User2 className="h-5 w-5" />
</div>
<Input
className={cn(
"h-9 pl-9 pr-3",
errors.username && "border-destructive focus-visible:ring-destructive"
)}
placeholder="用户名"
value={username}
onChange={(e) => {
setUsername(e.target.value)
setErrors({})
}}
disabled={loading}
/>
</div>
{errors.username && (
<p className="text-xs text-destructive">{errors.username}</p>
)}
</div>
<div className="space-y-1.5">
<div className="relative">
<div className="absolute left-2.5 top-2 text-muted-foreground">
<KeyRound className="h-5 w-5" />
</div>
<Input
className={cn(
"h-9 pl-9 pr-3",
errors.password && "border-destructive focus-visible:ring-destructive"
)}
type="password"
placeholder="密码"
value={password}
onChange={(e) => {
setPassword(e.target.value)
setErrors({})
}}
disabled={loading}
/>
</div>
{errors.password && (
<p className="text-xs text-destructive">{errors.password}</p>
)}
</div>
</div>
<div className="space-y-3 pt-1">
<Button
className="w-full"
onClick={handleLogin}
disabled={loading}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
<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-background px-2 text-muted-foreground">
</span>
</div>
</div>
<Button
variant="outline"
className="w-full"
onClick={handleGithubLogin}
>
<Github className="mr-2 h-4 w-4" />
使 GitHub
</Button>
</div>
</TabsContent>
<TabsContent value="register" className="space-y-4 mt-0">
<div className="space-y-3">
<div className="space-y-1.5">
<div className="relative">
<div className="absolute left-2.5 top-2 text-muted-foreground">
<User2 className="h-5 w-5" />
</div>
<Input
className={cn(
"h-9 pl-9 pr-3",
errors.username && "border-destructive focus-visible:ring-destructive"
)}
placeholder="用户名"
value={username}
onChange={(e) => {
setUsername(e.target.value)
setErrors({})
}}
disabled={loading}
/>
</div>
{errors.username && (
<p className="text-xs text-destructive">{errors.username}</p>
)}
</div>
<div className="space-y-1.5">
<div className="relative">
<div className="absolute left-2.5 top-2 text-muted-foreground">
<KeyRound className="h-5 w-5" />
</div>
<Input
className={cn(
"h-9 pl-9 pr-3",
errors.password && "border-destructive focus-visible:ring-destructive"
)}
type="password"
placeholder="密码"
value={password}
onChange={(e) => {
setPassword(e.target.value)
setErrors({})
}}
disabled={loading}
/>
</div>
{errors.password && (
<p className="text-xs text-destructive">{errors.password}</p>
)}
</div>
<div className="space-y-1.5">
<div className="relative">
<div className="absolute left-2.5 top-2 text-muted-foreground">
<KeyRound className="h-5 w-5" />
</div>
<Input
className={cn(
"h-9 pl-9 pr-3",
errors.confirmPassword && "border-destructive focus-visible:ring-destructive"
)}
type="password"
placeholder="确认密码"
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value)
setErrors({})
}}
disabled={loading}
/>
</div>
{errors.confirmPassword && (
<p className="text-xs text-destructive">{errors.confirmPassword}</p>
)}
</div>
</div>
<div className="space-y-3 pt-1">
<Button
className="w-full"
onClick={handleRegister}
disabled={loading}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</div>
</TabsContent>
</div>
</Tabs>
</CardContent>
</Card>
)
}

View File

@@ -2,23 +2,30 @@
import { Button } from "@/components/ui/button"
import Image from "next/image"
import { signIn, signOut, useSession } from "next-auth/react"
import { Github } from "lucide-react"
import { signOut, useSession } from "next-auth/react"
import { LogIn } from "lucide-react"
import { useRouter } from 'next/navigation'
import Link from "next/link"
import { cn } from "@/lib/utils"
export function SignButton() {
interface SignButtonProps {
size?: "default" | "lg"
}
export function SignButton({ size = "default" }: SignButtonProps) {
const router = useRouter()
const { data: session, status } = useSession()
const loading = status === "loading"
if (loading) {
return <div className="h-9" /> // 防止布局跳动
return <div className="h-9" />
}
if (!session?.user) {
return (
<Button onClick={() => signIn("github", { callbackUrl: "/moe" })} className="gap-2">
<Github className="w-4 h-4" />
使 GitHub
<Button onClick={() => router.push('/login')} className={cn("gap-2", size === "lg" ? "px-8" : "")} size={size}>
<LogIn className={size === "lg" ? "w-5 h-5" : "w-4 h-4"} />
/
</Button>
)
}
@@ -40,7 +47,7 @@ export function SignButton() {
)}
<span className="text-sm">{session.user.name}</span>
</Link>
<Button onClick={() => signOut({ callbackUrl: "/" })} variant="outline" className="flex-shrink-0">
<Button onClick={() => signOut({ callbackUrl: "/" })} variant="outline" className={cn("flex-shrink-0", size === "lg" ? "px-8" : "")} size={size}>
</Button>
</div>

View File

@@ -1,9 +1,9 @@
"use client"
import { Button } from "@/components/ui/button"
import { Mail, Github } from "lucide-react"
import { Mail } from "lucide-react"
import { useRouter } from "next/navigation"
import { signIn } from "next-auth/react"
import { SignButton } from "../auth/sign-button"
interface ActionButtonProps {
isLoggedIn?: boolean
@@ -25,14 +25,5 @@ export function ActionButton({ isLoggedIn }: ActionButtonProps) {
)
}
return (
<Button
size="lg"
onClick={() => signIn("github", { callbackUrl: "/moe" })}
className="gap-2 bg-primary hover:bg-primary/90 text-white px-8"
>
<Github className="w-5 h-5" />
使 GitHub
</Button>
)
return <SignButton size="lg" />
}

View File

@@ -47,10 +47,15 @@ export function ProfileCard({ user }: ProfileCardProps) {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold truncate">{user.name}</h2>
<div className="flex items-center gap-1 text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full flex-shrink-0">
<Github className="w-3 h-3" />
</div>
{
user.email && (
// 先简单实现,后续再完善
<div className="flex items-center gap-1 text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full flex-shrink-0">
<Github className="w-3 h-3" />
</div>
)
}
</div>
<p className="text-sm text-muted-foreground truncate mt-1">
{user.email}

View File

@@ -0,0 +1,78 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
}

View File

@@ -0,0 +1,54 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -2,10 +2,14 @@ import NextAuth from "next-auth"
import GitHub from "next-auth/providers/github"
import { DrizzleAdapter } from "@auth/drizzle-adapter"
import { createDb, Db } from "./db"
import { accounts, sessions, users, roles, userRoles } from "./schema"
import { accounts, users, roles, userRoles } from "./schema"
import { eq } from "drizzle-orm"
import { getRequestContext } from "@cloudflare/next-on-pages"
import { Permission, hasPermission, ROLES, Role } from "./permissions"
import CredentialsProvider from "next-auth/providers/credentials"
import { hashPassword, comparePassword } from "@/lib/utils"
import { authSchema } from "@/lib/validation"
import { generateAvatarUrl } from "./avatar"
const ROLE_DESCRIPTIONS: Record<Role, string> = {
[ROLES.EMPEROR]: "皇帝(网站所有者)",
@@ -71,13 +75,53 @@ export const {
adapter: DrizzleAdapter(createDb(), {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
}),
providers: [
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
})
}),
CredentialsProvider({
name: "Credentials",
credentials: {
username: { label: "用户名", type: "text", placeholder: "请输入用户名" },
password: { label: "密码", type: "password", placeholder: "请输入密码" },
},
async authorize(credentials) {
if (!credentials) {
throw new Error("请输入用户名和密码")
}
const { username, password } = credentials
try {
authSchema.parse({ username, password })
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
throw new Error("输入格式不正确")
}
const db = createDb()
const user = await db.query.users.findFirst({
where: eq(users.username, username as string),
})
if (!user) {
throw new Error("用户名或密码错误")
}
const isValid = await comparePassword(password as string, user.password as string)
if (!isValid) {
throw new Error("用户名或密码错误")
}
return {
...user,
password: undefined,
}
},
}),
],
events: {
async signIn({ user }) {
@@ -99,37 +143,73 @@ export const {
}
},
},
pages: {
signIn: "/",
error: "/",
},
callbacks: {
async session({ session, user }) {
if (!session?.user) return session
const db = createDb()
let userRoleRecords = await db.query.userRoles.findMany({
where: eq(userRoles.userId, user.id),
with: { role: true },
})
if (!userRoleRecords.length) {
const defaultRole = await getDefaultRole()
const role = await findOrCreateRole(db, defaultRole)
await assignRoleToUser(db, user.id, role.id)
userRoleRecords = [{
userId: user.id,
roleId: role.id,
createdAt: new Date(),
role: role
}]
async jwt({ token, user }) {
if (user) {
token.id = user.id
token.name = user.name || user.username
token.username = user.username
token.image = user.image || generateAvatarUrl(token.name as string)
}
return token
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string
session.user.name = token.name as string
session.user.username = token.username as string
session.user.image = token.image as string
session.user.roles = userRoleRecords.map(ur => ({
name: ur.role.name,
}))
const db = createDb()
let userRoleRecords = await db.query.userRoles.findMany({
where: eq(userRoles.userId, session.user.id),
with: { role: true },
})
if (!userRoleRecords.length) {
const defaultRole = await getDefaultRole()
const role = await findOrCreateRole(db, defaultRole)
await assignRoleToUser(db, session.user.id, role.id)
userRoleRecords = [{
userId: session.user.id,
roleId: role.id,
createdAt: new Date(),
role: role
}]
}
session.user.roles = userRoleRecords.map(ur => ({
name: ur.role.name,
}))
}
return session
},
}
},
session: {
strategy: "jwt",
},
}))
export async function register(username: string, password: string) {
const db = createDb()
const existing = await db.query.users.findFirst({
where: eq(users.username, username)
})
if (existing) {
throw new Error("用户名已存在")
}
const hashedPassword = await hashPassword(password)
const [user] = await db.insert(users)
.values({
username,
password: hashedPassword,
})
.returning()
return user
}

48
app/lib/avatar.ts Normal file
View File

@@ -0,0 +1,48 @@
const COLORS = [
'#2196F3', // 蓝色
'#009688', // 青色
'#9C27B0', // 紫色
'#F44336', // 红色
'#673AB7', // 深紫色
'#3F51B5', // 靛蓝
'#4CAF50', // 绿色
'#FF5722', // 深橙
'#795548', // 棕色
'#607D8B', // 蓝灰
];
export function generateAvatarUrl(name: string): string {
const initial = name[0].toUpperCase();
const colorIndex = Array.from(name).reduce(
(acc, char) => acc + char.charCodeAt(0), 0
) % COLORS.length;
const backgroundColor = COLORS[colorIndex];
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" fill="${backgroundColor}"/>
<text
x="50%"
y="50%"
fill="white"
font-family="system-ui, -apple-system, sans-serif"
font-size="45"
font-weight="500"
text-anchor="middle"
alignment-baseline="central"
dominant-baseline="central"
style="text-transform: uppercase"
>
${initial}
</text>
</svg>
`.trim();
const encoder = new TextEncoder();
const bytes = encoder.encode(svg);
const base64 = Buffer.from(bytes).toString('base64');
return `data:image/svg+xml;base64,${base64}`;
}

View File

@@ -11,8 +11,9 @@ export const users = sqliteTable("user", {
email: text("email").unique(),
emailVerified: integer("emailVerified", { mode: "timestamp_ms" }),
image: text("image"),
username: text("username").unique(),
password: text("password"),
})
export const accounts = sqliteTable(
"account",
{
@@ -36,15 +37,7 @@ export const accounts = sqliteTable(
}),
})
)
export const sessions = sqliteTable("session", {
sessionToken: text("sessionToken").primaryKey(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
})
export const emails = sqliteTable("email", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
address: text("address").notNull().unique(),
@@ -54,7 +47,7 @@ export const emails = sqliteTable("email", {
.$defaultFn(() => new Date()),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
})
export const messages = sqliteTable("message", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
emailId: text("emailId")
@@ -68,7 +61,7 @@ export const messages = sqliteTable("message", {
.notNull()
.$defaultFn(() => new Date()),
})
export const webhooks = sqliteTable('webhook', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text('user_id')

View File

@@ -3,4 +3,17 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
}
export async function hashPassword(password: string): Promise<string> {
const encoder = new TextEncoder()
const salt = process.env.AUTH_SECRET || ''
const data = encoder.encode(password + salt)
const hash = await crypto.subtle.digest('SHA-256', data)
return btoa(String.fromCharCode(...new Uint8Array(hash)))
}
export async function comparePassword(password: string, hashedPassword: string): Promise<boolean> {
const hash = await hashPassword(password)
return hash === hashedPassword
}

8
app/lib/validation.ts Normal file
View File

@@ -0,0 +1,8 @@
import { z } from "zod"
export const authSchema = z.object({
username: z.string().min(1, "用户名不能为空"),
password: z.string().min(8, "密码长度必须大于等于8位"),
})
export type AuthSchema = z.infer<typeof authSchema>

19
app/login/page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { LoginForm } from "@/components/auth/login-form"
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
export const runtime = "edge"
export default async function LoginPage() {
const session = await auth()
if (session?.user) {
redirect("/")
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
<LoginForm />
</div>
)
}

View File

@@ -0,0 +1,10 @@
CREATE TABLE `credential` (
`id` text PRIMARY KEY NOT NULL,
`userId` text NOT NULL,
`username` text NOT NULL,
`password` text NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `credential_username_unique` ON `credential` (`username`);

View File

@@ -0,0 +1,4 @@
DROP TABLE `credential`;--> statement-breakpoint
ALTER TABLE `user` ADD `username` text;--> statement-breakpoint
ALTER TABLE `user` ADD `password` text;--> statement-breakpoint
CREATE UNIQUE INDEX `user_username_unique` ON `user` (`username`);

View File

@@ -0,0 +1 @@
DROP TABLE `session`;

View File

@@ -0,0 +1,610 @@
{
"version": "6",
"dialect": "sqlite",
"id": "0b5d36d8-50f6-41fd-b5ec-7a611ee86a04",
"prevId": "1cde0c8f-dffe-4a01-bc08-678dc5879a13",
"tables": {
"account": {
"name": "account",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"columns": [
"provider",
"providerAccountId"
],
"name": "account_provider_providerAccountId_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"credential": {
"name": "credential",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"credential_username_unique": {
"name": "credential_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {
"credential_userId_user_id_fk": {
"name": "credential_userId_user_id_fk",
"tableFrom": "credential",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email": {
"name": "email",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"email_address_unique": {
"name": "email_address_unique",
"columns": [
"address"
],
"isUnique": true
}
},
"foreignKeys": {
"email_userId_user_id_fk": {
"name": "email_userId_user_id_fk",
"tableFrom": "email",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"message": {
"name": "message",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"emailId": {
"name": "emailId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"from_address": {
"name": "from_address",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"subject": {
"name": "subject",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"html": {
"name": "html",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"received_at": {
"name": "received_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"message_emailId_email_id_fk": {
"name": "message_emailId_email_id_fk",
"tableFrom": "message",
"tableTo": "email",
"columnsFrom": [
"emailId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"role": {
"name": "role",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_role": {
"name": "user_role",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role_id": {
"name": "role_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_role_user_id_user_id_fk": {
"name": "user_role_user_id_user_id_fk",
"tableFrom": "user_role",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_role_role_id_role_id_fk": {
"name": "user_role_role_id_role_id_fk",
"tableFrom": "user_role",
"tableTo": "role",
"columnsFrom": [
"role_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_role_user_id_role_id_pk": {
"columns": [
"user_id",
"role_id"
],
"name": "user_role_user_id_role_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"webhook": {
"name": "webhook",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"webhook_user_id_user_id_fk": {
"name": "webhook_user_id_user_id_fk",
"tableFrom": "webhook",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,564 @@
{
"version": "6",
"dialect": "sqlite",
"id": "2fe400b0-5da1-4678-a3c8-7433d35903c3",
"prevId": "0b5d36d8-50f6-41fd-b5ec-7a611ee86a04",
"tables": {
"account": {
"name": "account",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"columns": [
"provider",
"providerAccountId"
],
"name": "account_provider_providerAccountId_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email": {
"name": "email",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"email_address_unique": {
"name": "email_address_unique",
"columns": [
"address"
],
"isUnique": true
}
},
"foreignKeys": {
"email_userId_user_id_fk": {
"name": "email_userId_user_id_fk",
"tableFrom": "email",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"message": {
"name": "message",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"emailId": {
"name": "emailId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"from_address": {
"name": "from_address",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"subject": {
"name": "subject",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"html": {
"name": "html",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"received_at": {
"name": "received_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"message_emailId_email_id_fk": {
"name": "message_emailId_email_id_fk",
"tableFrom": "message",
"tableTo": "email",
"columnsFrom": [
"emailId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"role": {
"name": "role",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_role": {
"name": "user_role",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role_id": {
"name": "role_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_role_user_id_user_id_fk": {
"name": "user_role_user_id_user_id_fk",
"tableFrom": "user_role",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_role_role_id_role_id_fk": {
"name": "user_role_role_id_role_id_fk",
"tableFrom": "user_role",
"tableTo": "role",
"columnsFrom": [
"role_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_role_user_id_role_id_pk": {
"columns": [
"user_id",
"role_id"
],
"name": "user_role_user_id_role_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"user_username_unique": {
"name": "user_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"webhook": {
"name": "webhook",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"webhook_user_id_user_id_fk": {
"name": "webhook_user_id_user_id_fk",
"tableFrom": "webhook",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,519 @@
{
"version": "6",
"dialect": "sqlite",
"id": "144530f0-42a9-450d-ab24-a419eb7b4f65",
"prevId": "2fe400b0-5da1-4678-a3c8-7433d35903c3",
"tables": {
"account": {
"name": "account",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"columns": [
"provider",
"providerAccountId"
],
"name": "account_provider_providerAccountId_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email": {
"name": "email",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"email_address_unique": {
"name": "email_address_unique",
"columns": [
"address"
],
"isUnique": true
}
},
"foreignKeys": {
"email_userId_user_id_fk": {
"name": "email_userId_user_id_fk",
"tableFrom": "email",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"message": {
"name": "message",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"emailId": {
"name": "emailId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"from_address": {
"name": "from_address",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"subject": {
"name": "subject",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"html": {
"name": "html",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"received_at": {
"name": "received_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"message_emailId_email_id_fk": {
"name": "message_emailId_email_id_fk",
"tableFrom": "message",
"tableTo": "email",
"columnsFrom": [
"emailId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"role": {
"name": "role",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_role": {
"name": "user_role",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role_id": {
"name": "role_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"user_role_user_id_user_id_fk": {
"name": "user_role_user_id_user_id_fk",
"tableFrom": "user_role",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_role_role_id_role_id_fk": {
"name": "user_role_role_id_role_id_fk",
"tableFrom": "user_role",
"tableTo": "role",
"columnsFrom": [
"role_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"user_role_user_id_role_id_pk": {
"columns": [
"user_id",
"role_id"
],
"name": "user_role_user_id_role_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"user_username_unique": {
"name": "user_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"webhook": {
"name": "webhook",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"webhook_user_id_user_id_fk": {
"name": "webhook_user_id_user_id_fk",
"tableFrom": "webhook",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -43,6 +43,27 @@
"when": 1735273566991,
"tag": "0005_tricky_mathemanic",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1735966405003,
"tag": "0006_thick_lester",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1735971121264,
"tag": "0007_careless_sentinel",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1736922488093,
"tag": "0008_cute_dormammu",
"breakpoints": true
}
]
}

View File

@@ -24,11 +24,13 @@
"@cloudflare/next-on-pages": "^1.13.6",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-radio-group": "^1.2.1",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.1.6",
"@tailwindcss/typography": "^0.5.15",

132
pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
'@radix-ui/react-dialog':
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.4
version: 2.1.4(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-label':
specifier: ^2.1.0
version: 2.1.0(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -35,6 +38,9 @@ importers:
'@radix-ui/react-switch':
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-tabs':
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-toast':
specifier: ^1.1.5
version: 1.2.2(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -1739,6 +1745,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-dropdown-menu@2.1.4':
resolution: {integrity: sha512-iXU1Ab5ecM+yEepGAWK8ZhMyKX4ubFdCNtol4sT9D0OVErG9PNElfx3TQhjw7n7BC5nFVz68/5//clWy+8TXzA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-focus-guards@1.1.1':
resolution: {integrity: sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==}
peerDependencies:
@@ -1796,6 +1815,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-menu@2.1.4':
resolution: {integrity: sha512-BnOgVoL6YYdHAG6DtXONaR29Eq4nvbi8rutrV/xlr3RQCMMb3yqP85Qiw/3NReozrSW+4dfLkK+rc1hb4wPU/A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.1':
resolution: {integrity: sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw==}
peerDependencies:
@@ -1913,6 +1945,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.1':
resolution: {integrity: sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.1.4':
resolution: {integrity: sha512-pOkb2u8KgO47j/h7AylCj7dJsm69BXcjkrvTqMptFqsE2i0p8lHkfgneXKjAgPzBMivnoMyt8o4KiV4wYzDdyQ==}
peerDependencies:
@@ -1957,6 +2002,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-tabs@1.1.2':
resolution: {integrity: sha512-9u/tQJMcC2aGq7KXpGivMm1mgq7oRJKXphDwdypPd/j21j/2znamPU8WkXgnhUaTrSFNIt8XhOyCAupg8/GbwQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-toast@1.2.2':
resolution: {integrity: sha512-Z6pqSzmAP/bFJoqMAston4eSNa+ud44NSZTiZUmUen+IOZ5nBY8kzuU5WDBVyFXPtcW6yUalOHsxM/BP6Sv8ww==}
peerDependencies:
@@ -6981,6 +7039,21 @@ snapshots:
'@types/react': 18.3.14
'@types/react-dom': 18.3.2
'@radix-ui/react-dropdown-menu@2.1.4(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.14)(react@19.0.0)
'@radix-ui/react-context': 1.1.1(@types/react@18.3.14)(react@19.0.0)
'@radix-ui/react-id': 1.1.0(@types/react@18.3.14)(react@19.0.0)
'@radix-ui/react-menu': 2.1.4(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.14)(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 18.3.14
'@types/react-dom': 18.3.2
'@radix-ui/react-focus-guards@1.1.1(@types/react@18.3.14)(react@19.0.0)':
dependencies:
react: 19.0.0
@@ -7025,6 +7098,32 @@ snapshots:
'@types/react': 18.3.14
'@types/react-dom': 18.3.2
'@radix-ui/react-menu@2.1.4(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-collection': 1.1.1(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.14)(react@19.0.0)
'@radix-ui/react-context': 1.1.1(@types/react@18.3.14)(react@19.0.0)
'@radix-ui/react-direction': 1.1.0(@types/react@18.3.14)(react@19.0.0)
'@radix-ui/react-dismissable-layer': 1.1.3(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-focus-guards': 1.1.1(@types/react@18.3.14)(react@19.0.0)
'@radix-ui/react-focus-scope': 1.1.1(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-id': 1.1.0(@types/react@18.3.14)(react@19.0.0)
'@radix-ui/react-popper': 1.2.1(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-portal': 1.1.3(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-roving-focus': 1.1.1(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-slot': 1.1.1(@types/react@18.3.14)(react@19.0.0)
'@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.14)(react@19.0.0)
aria-hidden: 1.2.4
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-remove-scroll: 2.6.2(@types/react@18.3.14)(react@19.0.0)
optionalDependencies:
'@types/react': 18.3.14
'@types/react-dom': 18.3.2
'@radix-ui/react-popper@1.2.1(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -7136,6 +7235,23 @@ snapshots:
'@types/react': 18.3.14
'@types/react-dom': 18.3.2
'@radix-ui/react-roving-focus@1.1.1(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-collection': 1.1.1(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.14)(react@19.0.0)
'@radix-ui/react-context': 1.1.1(@types/react@18.3.14)(react@19.0.0)
'@radix-ui/react-direction': 1.1.0(@types/react@18.3.14)(react@19.0.0)
'@radix-ui/react-id': 1.1.0(@types/react@18.3.14)(react@19.0.0)
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.14)(react@19.0.0)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.14)(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 18.3.14
'@types/react-dom': 18.3.2
'@radix-ui/react-select@2.1.4(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/number': 1.1.0
@@ -7194,6 +7310,22 @@ snapshots:
'@types/react': 18.3.14
'@types/react-dom': 18.3.2
'@radix-ui/react-tabs@1.1.2(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-context': 1.1.1(@types/react@18.3.14)(react@19.0.0)
'@radix-ui/react-direction': 1.1.0(@types/react@18.3.14)(react@19.0.0)
'@radix-ui/react-id': 1.1.0(@types/react@18.3.14)(react@19.0.0)
'@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-roving-focus': 1.1.1(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.14)(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 18.3.14
'@types/react-dom': 18.3.2
'@radix-ui/react-toast@1.2.2(@types/react-dom@18.3.2)(@types/react@18.3.14)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.0

1
types.d.ts vendored
View File

@@ -13,6 +13,7 @@ declare global {
declare module "next-auth" {
interface User {
roles?: { name: string }[]
username?: string | null
}
interface Session {