Added passkey support

This commit is contained in:
2025-08-07 23:03:17 +02:00
parent 28e4f8467b
commit 0a4f5a7d36
17 changed files with 1133 additions and 86 deletions

View File

@@ -0,0 +1,111 @@
"use client"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { passkey } from "@/lib/auth/auth-client"
import { zodResolver } from "@hookform/resolvers/zod"
import { KeyRoundIcon } from "lucide-react"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
export function PasskeyAdd() {
const router = useRouter()
const form = useForm<{ name: string }>({
resolver: zodResolver(z.object({ name: z.string().min(1).max(50) })),
defaultValues: { name: "passkey" }
})
async function handleSubmit({ name }: { name: string }) {
const res = await passkey.addPasskey({ name })
if (res && res.error) {
toast.error(res.error.message)
return
}
toast.success("Passkey added successfully")
router.refresh()
form.reset({ name: "" })
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4 mb-5 max-w-48">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Adding..." : "Add Passkey"}
</Button>
</form>
</Form>
)
}
export function PasskeyItem({ id, name, createdAt }: { id: string, name?: string, createdAt: Date }) {
const [isLoading, setIsLoading] = useState(false)
const router = useRouter()
async function handleDelete() {
setIsLoading(true)
await passkey.deletePasskey({ id })
router.refresh()
}
return (
<Card className="mb-2">
<CardContent>
<p className="font-medium flex gap-2">
<span>
<KeyRoundIcon />
</span>
<span>{name}</span>
</p>
<p className="text-sm text-muted-foreground">
Created at: {new Date(createdAt).toLocaleString()}
</p>
<Dialog>
<DialogTrigger asChild className="mt-2">
<Button variant="destructive">
Delete
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the passkey.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">
Cancel
</Button>
</DialogClose>
<Button className="ml-2" variant="destructive" disabled={isLoading} onClick={handleDelete}>
{isLoading ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,26 @@
import { auth } from "@/lib/auth/auth"
import { headers } from "next/headers"
import { PasskeyItem } from "./passkey"
export default async function PasskeysList() {
const passkeys = await auth.api.listPasskeys({
headers: await headers()
})
return (
<div className="space-y-4">
{passkeys.length === 0 ? <p className="text-sm text-muted-foreground">No passkeys registered.</p> : (
<div className="max-w-md">
{passkeys.map(passkey => (
<PasskeyItem
key={passkey.id}
id={passkey.id}
name={passkey.name}
createdAt={passkey.createdAt}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -14,6 +14,7 @@ import { signOut, useSession } from "@/lib/auth/auth-client"
import { LogOut, User } from "lucide-react" import { LogOut, User } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner"
export function SidebarUserDropdown() { export function SidebarUserDropdown() {
const { data: session, isPending } = useSession() const { data: session, isPending } = useSession()
@@ -21,16 +22,16 @@ export function SidebarUserDropdown() {
const router = useRouter() const router = useRouter()
const handleSignOut = async () => { const handleSignOut = async () => {
try { const res = await signOut({
await signOut({ fetchOptions: {
fetchOptions: { onSuccess: () => {
onSuccess: () => { router.push("/")
router.push("/")
}
} }
}) }
} catch (err) { })
console.error("Failed to sign out:", err)
if (res && res.error) {
toast.error(res.error.message)
} }
} }

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { LayoutDashboard, List, PanelLeft, Plus } from "lucide-react" import { HomeIcon, LayoutDashboard, List, PanelLeft, Plus, User2 } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
@@ -58,6 +58,23 @@ export function DashboardSidebar() {
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>
Menu
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Home">
<Link href="/">
<HomeIcon />
<span>Home</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden"> <SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">
Navigation Navigation
@@ -81,6 +98,27 @@ export function DashboardSidebar() {
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>
Other
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={pathname === "/dashboard/user"}
tooltip="User Profile"
>
<Link href="/dashboard/user">
<User2 />
<span>User Profile</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
<SidebarThemeToggle /> <SidebarThemeToggle />

View File

@@ -0,0 +1,22 @@
import { getSession } from "@/lib/auth/session"
import { PasskeyAdd } from "../_components/passkey"
import PasskeysList from "../_components/passkeys-list"
export default async function UserPage() {
const { session, redirect } = await getSession()
if (!session) {
redirect("/login")
}
return (
<div className="p-6">
<div className="mb-6">
<h1 className="block mb-2 text-2xl font-bold text-foreground">User Profile</h1>
<h1 className="block text-muted-foreground">Manage user settings</h1>
</div>
<PasskeyAdd />
<PasskeysList />
</div>
)
}

View File

@@ -3,8 +3,10 @@
import { AuthentikIcon } from "@/components/svgs/authentik" import { AuthentikIcon } from "@/components/svgs/authentik"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { signIn } from "@/lib/auth/auth-client" import { signIn } from "@/lib/auth/auth-client"
import { Loader2 } from "lucide-react" import { KeyIcon, Loader2 } from "lucide-react"
import { useRouter } from "next/navigation"
import { useState } from "react" import { useState } from "react"
import { toast } from "sonner"
interface OAuthSignInButtonProps { interface OAuthSignInButtonProps {
className?: string className?: string
@@ -14,40 +16,85 @@ export function OAuthSignInButton({
className className
}: OAuthSignInButtonProps) { }: OAuthSignInButtonProps) {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const router = useRouter()
const handleSignIn = async () => { const handleOAuthSignIn = async () => {
try { setIsLoading(true)
setIsLoading(true) const res = await signIn.oauth2({
await signIn.oauth2({ providerId: "authentik",
providerId: "authentik", callbackURL: "/dashboard"
callbackURL: "/dashboard" })
})
} catch (error) { if (res && res.error) {
console.error("Sign in failed:", error) toast.error(res.error.message)
setIsLoading(false) setIsLoading(false)
return
} }
toast.success("Successfully logged in!")
}
const handlePasskeySignIn = async () => {
setIsLoading(true)
const res = await signIn.passkey({
fetchOptions: {
onSuccess: () => {
router.push("/dashboard")
}
}
})
if (res && res.error) {
toast.error(res.error.message)
setIsLoading(false)
return
}
toast.success("Successfully logged in!")
} }
return ( return (
<Button <>
onClick={handleSignIn} <Button
disabled={isLoading} onClick={handleOAuthSignIn}
className={className} disabled={isLoading}
size="lg" className={className}
> size="lg"
{isLoading ? >
( {isLoading ?
<> (
<Loader2 className="mr-2 w-4 h-4 animate-spin" /> <>
Signing in... <Loader2 className="mr-2 w-4 h-4 animate-spin" />
</> Signing in...
) : </>
( ) :
<> (
<AuthentikIcon className="mr-2 w-5 h-5" /> <>
Sign in with Authentik <AuthentikIcon className="mr-2 w-5 h-5" />
</> Sign in with Authentik
)} </>
</Button> )}
</Button>
<Button
onClick={handlePasskeySignIn}
disabled={isLoading}
className={className}
size="lg"
>
{isLoading ?
(
<>
<Loader2 className="mr-2 w-4 h-4 animate-spin" />
Signing in...
</>
) :
(
<>
<KeyIcon className="mr-2 w-5 h-5" />
Sign in with Passkey
</>
)}
</Button>
</>
) )
} }

View File

@@ -0,0 +1,132 @@
"use client"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger }

View File

@@ -14,6 +14,7 @@ import { signOut, useSession } from "@/lib/auth/auth-client"
import { LogOut, Settings } from "lucide-react" import { LogOut, Settings } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner"
interface UserDropdownProps { interface UserDropdownProps {
className?: string className?: string
@@ -24,16 +25,17 @@ export function UserDropdown({ className }: UserDropdownProps) {
const router = useRouter() const router = useRouter()
const handleSignOut = async () => { const handleSignOut = async () => {
try { const res = await signOut({
await signOut({ fetchOptions: {
fetchOptions: { onSuccess: () => {
onSuccess: () => { router.push("/")
router.push("/")
}
} }
}) }
} catch (err) { })
console.error("Failed to sign out:", err)
if (res && res.error) {
toast.error(res.error.message)
return
} }
} }

View File

@@ -1,6 +1,6 @@
import { genericOAuthClient } from "better-auth/client/plugins" import { genericOAuthClient, passkeyClient } from "better-auth/client/plugins"
import { createAuthClient } from "better-auth/react" import { createAuthClient } from "better-auth/react"
export const { signIn, signOut, useSession } = createAuthClient({ export const { signIn, signOut, useSession, passkey } = createAuthClient({
plugins: [genericOAuthClient()] plugins: [genericOAuthClient(), passkeyClient()]
}) })

View File

@@ -2,6 +2,7 @@ import { betterAuth } from "better-auth"
import { drizzleAdapter } from "better-auth/adapters/drizzle" import { drizzleAdapter } from "better-auth/adapters/drizzle"
import { nextCookies } from "better-auth/next-js" import { nextCookies } from "better-auth/next-js"
import { genericOAuth } from "better-auth/plugins" import { genericOAuth } from "better-auth/plugins"
import { passkey } from "better-auth/plugins/passkey"
import { db } from "../drizzle/db" import { db } from "../drizzle/db"
import { env } from "../env/server" import { env } from "../env/server"
import { getGravatar } from "../gravatar" import { getGravatar } from "../gravatar"
@@ -37,6 +38,7 @@ export const auth = betterAuth({
clientSecret: env.AUTHENTIK_CLIENT_SECRET, clientSecret: env.AUTHENTIK_CLIENT_SECRET,
discoveryUrl: env.AUTHENTIK_DISCOVERY_URL discoveryUrl: env.AUTHENTIK_DISCOVERY_URL
}] }]
}) }),
passkey()
] ]
}) })

View File

@@ -3,33 +3,24 @@ import { db } from "../drizzle/db"
import { urls, visits } from "../drizzle/schema" import { urls, visits } from "../drizzle/schema"
export async function getDashboardStats() { export async function getDashboardStats() {
try { const [urlsCount] = await db.select({ count: count() }).from(urls)
const [urlsCount] = await db.select({ count: count() }).from(urls) const [visitsCount] = await db.select({ count: count() }).from(visits)
const [visitsCount] = await db.select({ count: count() }).from(visits) const mostVisitedUrl = await db
const mostVisitedUrl = await db .select({
.select({ id: urls.id,
id: urls.id, title: urls.title,
title: urls.title, slug: urls.slug,
slug: urls.slug, visitCount: count(visits.id)
visitCount: count(visits.id) })
}) .from(urls)
.from(urls) .leftJoin(visits, eq(urls.id, visits.urlId))
.leftJoin(visits, eq(urls.id, visits.urlId)) .groupBy(urls.id, urls.title, urls.slug)
.groupBy(urls.id, urls.title, urls.slug) .orderBy(desc(count(visits.id)))
.orderBy(desc(count(visits.id))) .limit(1)
.limit(1)
return { return {
totalUrls: urlsCount.count, totalUrls: urlsCount.count,
totalVisits: visitsCount.count, totalVisits: visitsCount.count,
mostVisitedUrl: mostVisitedUrl[0] || null mostVisitedUrl: mostVisitedUrl[0] || null
}
} catch (error) {
console.error("Failed to fetch dashboard stats:", error)
return {
totalUrls: 0,
totalVisits: 0,
mostVisitedUrl: null
}
} }
} }

View File

@@ -1,4 +1,4 @@
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core" import { boolean, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"
export const user = pgTable("user", { export const user = pgTable("user", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
@@ -45,3 +45,17 @@ export const verification = pgTable("verification", {
createdAt: timestamp("created_at").$defaultFn(() => new Date()), createdAt: timestamp("created_at").$defaultFn(() => new Date()),
updatedAt: timestamp("updated_at").$defaultFn(() => new Date()) updatedAt: timestamp("updated_at").$defaultFn(() => new Date())
}) })
export const passkey = pgTable("passkey", {
id: text("id").primaryKey(),
name: text("name"),
publicKey: text("public_key").notNull(),
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
credentialID: text("credential_i_d").notNull(),
counter: integer("counter").notNull(),
deviceType: text("device_type").notNull(),
backedUp: boolean("backed_up").notNull(),
transports: text("transports"),
createdAt: timestamp("created_at"),
aaguid: text("aaguid")
})

View File

@@ -0,0 +1,15 @@
CREATE TABLE "passkey" (
"id" text PRIMARY KEY NOT NULL,
"name" text,
"public_key" text NOT NULL,
"user_id" text NOT NULL,
"credential_i_d" text NOT NULL,
"counter" integer NOT NULL,
"device_type" text NOT NULL,
"backed_up" boolean NOT NULL,
"transports" text,
"created_at" timestamp,
"aaguid" text
);
--> statement-breakpoint
ALTER TABLE "passkey" ADD CONSTRAINT "passkey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,626 @@
{
"id": "4f8ffa1f-1feb-4438-8743-722c9a6b601e",
"prevId": "87caaf54-7746-4c06-974e-384f1111759c",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.passkey": {
"name": "passkey",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"credential_i_d": {
"name": "credential_i_d",
"type": "text",
"primaryKey": false,
"notNull": true
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"device_type": {
"name": "device_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"backed_up": {
"name": "backed_up",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"transports": {
"name": "transports",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"aaguid": {
"name": "aaguid",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"passkey_user_id_user_id_fk": {
"name": "passkey_user_id_user_id_fk",
"tableFrom": "passkey",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.urls": {
"name": "urls",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"url": {
"name": "url",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "varchar",
"primaryKey": false,
"notNull": false
},
"max_visits": {
"name": "max_visits",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"exp_date": {
"name": "exp_date",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"forward_query_params": {
"name": "forward_query_params",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"crawable": {
"name": "crawable",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"urls_slug_idx": {
"name": "urls_slug_idx",
"columns": [
{
"expression": "slug",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"urls_url_idx": {
"name": "urls_url_idx",
"columns": [
{
"expression": "url",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"exp_date_idx": {
"name": "exp_date_idx",
"columns": [
{
"expression": "exp_date",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"urls_slug_unique": {
"name": "urls_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.visits": {
"name": "visits",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"url_id": {
"name": "url_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"user_agent": {
"name": "user_agent",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"visits_url_id_idx": {
"name": "visits_url_id_idx",
"columns": [
{
"expression": "url_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"visits_url_id_urls_id_fk": {
"name": "visits_url_id_urls_id_fk",
"tableFrom": "visits",
"tableTo": "urls",
"columnsFrom": [
"url_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1751119888991, "when": 1751119888991,
"tag": "0000_shallow_stryfe", "tag": "0000_shallow_stryfe",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1754582718320,
"tag": "0001_jazzy_meltdown",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,4 +1,4 @@
export { account, session, user, verification } from "./auth-schema" export * from "./auth-schema"
import { relations } from "drizzle-orm" import { relations } from "drizzle-orm"
import { boolean, index, integer, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core" import { boolean, index, integer, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"

View File

@@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@@ -19,9 +23,18 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": [
"./src/*"
]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }