proper user/session creation and auth integration into UI

This commit is contained in:
2025-08-19 03:40:06 -04:00
parent 1a13013b45
commit 12e9ec5d85
12 changed files with 726 additions and 94 deletions

View File

@@ -0,0 +1,56 @@
-- Current sql file was generated after introspecting the database
-- If you want to run this migration please uncomment this code before executing migrations
/*
CREATE TABLE "session" (
"sessionToken" text PRIMARY KEY NOT NULL,
"userId" text NOT NULL,
"expires" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text,
"email" text NOT NULL,
"emailVerified" timestamp,
"image" text
);
--> statement-breakpoint
CREATE TABLE "verificationToken" (
"identifier" text NOT NULL,
"token" text NOT NULL,
"expires" timestamp NOT NULL,
CONSTRAINT "verificationToken_identifier_token_pk" PRIMARY KEY("identifier","token")
);
--> statement-breakpoint
CREATE TABLE "authenticator" (
"credentialID" text NOT NULL,
"userId" text NOT NULL,
"providerAccountId" text NOT NULL,
"credentialPublicKey" text NOT NULL,
"counter" integer NOT NULL,
"credentialDeviceType" text NOT NULL,
"credentialBackedUp" boolean NOT NULL,
"transports" text,
CONSTRAINT "authenticator_userId_credentialID_pk" PRIMARY KEY("credentialID","userId"),
CONSTRAINT "authenticator_credentialID_unique" UNIQUE("credentialID")
);
--> statement-breakpoint
CREATE TABLE "account" (
"userId" text NOT NULL,
"type" text NOT NULL,
"provider" text NOT NULL,
"providerAccountId" text NOT NULL,
"refresh_token" text,
"access_token" text,
"expires_at" text,
"token_type" text,
"scope" text,
"id_token" text,
"session_state" text,
CONSTRAINT "account_provider_providerAccountId_pk" PRIMARY KEY("provider","providerAccountId")
);
--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "authenticator" ADD CONSTRAINT "authenticator_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
*/

View File

@@ -0,0 +1,344 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"prevId": "",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.session": {
"name": "session",
"schema": "",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"notNull": true
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"schemaTo": "public",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {},
"policies": {},
"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": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"emailVerified": {
"name": "emailVerified",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {},
"policies": {},
"isRLSEnabled": false
},
"public.verificationToken": {
"name": "verificationToken",
"schema": "",
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires": {
"name": "expires",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verificationToken_identifier_token_pk": {
"name": "verificationToken_identifier_token_pk",
"columns": [
"identifier",
"token"
]
}
},
"uniqueConstraints": {},
"checkConstraints": {},
"policies": {},
"isRLSEnabled": false
},
"public.authenticator": {
"name": "authenticator",
"schema": "",
"columns": {
"credentialID": {
"name": "credentialID",
"type": "text",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"credentialPublicKey": {
"name": "credentialPublicKey",
"type": "text",
"primaryKey": false,
"notNull": true
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"credentialDeviceType": {
"name": "credentialDeviceType",
"type": "text",
"primaryKey": false,
"notNull": true
},
"credentialBackedUp": {
"name": "credentialBackedUp",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"transports": {
"name": "transports",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"authenticator_userId_user_id_fk": {
"name": "authenticator_userId_user_id_fk",
"tableFrom": "authenticator",
"tableTo": "user",
"schemaTo": "public",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"authenticator_userId_credentialID_pk": {
"name": "authenticator_userId_credentialID_pk",
"columns": [
"credentialID",
"userId"
]
}
},
"uniqueConstraints": {
"authenticator_credentialID_unique": {
"columns": [
"credentialID"
],
"nullsNotDistinct": false,
"name": "authenticator_credentialID_unique"
}
},
"checkConstraints": {},
"policies": {},
"isRLSEnabled": false
},
"public.account": {
"name": "account",
"schema": "",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "text",
"primaryKey": false,
"notNull": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"schemaTo": "public",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"name": "account_provider_providerAccountId_pk",
"columns": [
"provider",
"providerAccountId"
]
}
},
"uniqueConstraints": {},
"checkConstraints": {},
"policies": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1755586325384,
"tag": "0000_loose_catseye",
"breakpoints": true
}
]
}

29
drizzle/relations.ts Normal file
View File

@@ -0,0 +1,29 @@
import { relations } from "drizzle-orm/relations";
import { user, session, authenticator, account } from "./schema";
export const sessionRelations = relations(session, ({one}) => ({
user: one(user, {
fields: [session.userId],
references: [user.id]
}),
}));
export const userRelations = relations(user, ({many}) => ({
sessions: many(session),
authenticators: many(authenticator),
accounts: many(account),
}));
export const authenticatorRelations = relations(authenticator, ({one}) => ({
user: one(user, {
fields: [authenticator.userId],
references: [user.id]
}),
}));
export const accountRelations = relations(account, ({one}) => ({
user: one(user, {
fields: [account.userId],
references: [user.id]
}),
}));

72
drizzle/schema.ts Normal file
View File

@@ -0,0 +1,72 @@
import { pgTable, foreignKey, text, timestamp, primaryKey, unique, integer, boolean } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"
export const session = pgTable("session", {
sessionToken: text().primaryKey().notNull(),
userId: text().notNull(),
expires: timestamp({ mode: 'string' }).notNull(),
}, (table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "session_userId_user_id_fk"
}).onDelete("cascade"),
]);
export const user = pgTable("user", {
id: text().primaryKey().notNull(),
name: text(),
email: text().notNull(),
emailVerified: timestamp({ mode: 'string' }),
image: text(),
});
export const verificationToken = pgTable("verificationToken", {
identifier: text().notNull(),
token: text().notNull(),
expires: timestamp({ mode: 'string' }).notNull(),
}, (table) => [
primaryKey({ columns: [table.identifier, table.token], name: "verificationToken_identifier_token_pk"}),
]);
export const authenticator = pgTable("authenticator", {
credentialId: text().notNull(),
userId: text().notNull(),
providerAccountId: text().notNull(),
credentialPublicKey: text().notNull(),
counter: integer().notNull(),
credentialDeviceType: text().notNull(),
credentialBackedUp: boolean().notNull(),
transports: text(),
}, (table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "authenticator_userId_user_id_fk"
}).onDelete("cascade"),
primaryKey({ columns: [table.credentialId, table.userId], name: "authenticator_userId_credentialID_pk"}),
unique("authenticator_credentialID_unique").on(table.credentialId),
]);
export const account = pgTable("account", {
userId: text().notNull(),
type: text().notNull(),
provider: text().notNull(),
providerAccountId: text().notNull(),
refreshToken: text("refresh_token"),
accessToken: text("access_token"),
expiresAt: text("expires_at"),
tokenType: text("token_type"),
scope: text(),
idToken: text("id_token"),
sessionState: text("session_state"),
}, (table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "account_userId_user_id_fk"
}).onDelete("cascade"),
primaryKey({ columns: [table.provider, table.providerAccountId], name: "account_provider_providerAccountId_pk"}),
]);

View File

@@ -1,6 +1,16 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
export async function POST(request: Request) {
const session = await auth();
if (!session?.user) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
);
}
const { prompt } = await request.json();
const systemPrompt = `

View File

@@ -1,37 +1,43 @@
import { signIn, signOut } from "@/auth"
import { auth } from "@/auth"
import { Button } from "@/components/ui/button"
"use client"
export default async function SignIn() {
const session = await auth()
import { signOut, useSession } from "next-auth/react"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
export default function SignIn() {
const { data: session, status } = useSession()
const router = useRouter()
const handleSignOut = async () => {
await signOut({ redirect: false })
router.push("/")
router.refresh()
}
if (status === "loading") {
return <div className="h-8 w-16 bg-muted animate-pulse rounded"></div>
}
if (session?.user) {
return (
<div className="flex items-center gap-4">
<form
action={async () => {
"use server"
await signOut()
}}
>
<Button type="submit" variant="secondary" >
Sign Out
</Button>
</form>
<span className="text-sm text-muted-foreground hidden sm:inline">
{session.user.name || session.user.email}
</span>
<Button onClick={handleSignOut} variant="ghost" size="sm">
Sign Out
</Button>
</div>
)
}
return (
<form
action={async () => {
"use server"
await signIn("authentik")
}}
<Button
onClick={() => router.push("/signin")}
variant="outline"
size="sm"
>
<Button type="submit" variant="default" >
Sign In
</Button>
</form>
Sign In
</Button>
)
}

View File

@@ -143,6 +143,13 @@ export default function HomePage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: aiPrompt })
})
if (res.status === 401) {
alert('Please sign in to use AI features.')
setAiLoading(false)
return
}
const data = await res.json()
if (Array.isArray(data) && data.length > 0) {
@@ -225,25 +232,32 @@ export default function HomePage() {
<div className='max-w-fit m-auto'> Drag & Drop *.ics here</div>
</div>
{/* AI Toolbar */}
<div className="flex flex-row gap-4 mb-4 items-start">
{session?.user && (
<>
<div className='w-full'>
<Textarea
className="wrap-anywhere min-h-12"
placeholder='Describe event for AI to create'
value={aiPrompt}
onChange={e => setAiPrompt(e.target.value)}
/>
</div>
<div className='flex flex-row gap-2 pt-1.5'>
<Button onClick={handleAiCreate} disabled={aiLoading}>
{aiLoading ? 'Thinking...' : 'AI Create'}
</Button>
</div>
</>
)}
</div>
{session?.user ? (
<div className="flex flex-row gap-4 mb-4 items-start">
<div className='w-full'>
<Textarea
className="wrap-anywhere min-h-12"
placeholder='Describe event for AI to create'
value={aiPrompt}
onChange={e => setAiPrompt(e.target.value)}
/>
</div>
<div className='flex flex-row gap-2 pt-1.5'>
<Button onClick={handleAiCreate} disabled={aiLoading}>
{aiLoading ? 'Thinking...' : 'AI Create'}
</Button>
</div>
</div>
) : (
<div className="mb-4 p-4 border border-dashed rounded-lg text-center">
<div className="text-sm text-muted-foreground mb-2">
Sign in to unlock AI-powered calendar features
</div>
<Button variant="outline" size="sm" asChild>
<a href="/signin">Sign In</a>
</Button>
</div>
)}
{/* Summary Panel */}
{

45
src/app/signin/page.tsx Normal file
View File

@@ -0,0 +1,45 @@
import { signIn, auth } from "@/auth"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { redirect } from "next/navigation"
import Link from "next/link"
export default async function SignInPage() {
const session = await auth()
// If already signed in, redirect to home
if (session?.user) {
redirect("/")
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Welcome</CardTitle>
<CardDescription>
Sign in to access AI-powered calendar features
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form
action={async () => {
"use server"
await signIn("authentik", { redirectTo: "/" })
}}
>
<Button type="submit" className="w-full" size="lg">
Continue with Authentik
</Button>
</form>
<div className="text-center">
<Link href="/" className="text-sm text-muted-foreground hover:underline">
Continue without signing in
</Link>
</div>
</CardContent>
</Card>
</div>
)
}

49
src/app/signout/page.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { signOut, auth } from "@/auth"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { redirect } from "next/navigation"
import Link from "next/link"
export default async function SignOutPage() {
const session = await auth()
if (!session) {
redirect("/")
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Sign Out</CardTitle>
<CardDescription>
Are you sure you want to sign out?
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-center p-3 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground">Currently signed in as</div>
<div className="font-medium">{session.user?.name || session.user?.email}</div>
</div>
<div className="grid grid-cols-2 gap-3">
<form
action={async () => {
"use server"
await signOut({ redirectTo: "/" })
}}
>
<Button type="submit" variant="destructive" className="w-full">
Sign Out
</Button>
</form>
<Button variant="outline" asChild>
<Link href="/">Cancel</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,36 +1,55 @@
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { pgTable, text, timestamp, integer, boolean, primaryKey } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull().unique(),
export const users = pgTable('user', {
id: text('id').primaryKey(),
name: text('name'),
email: text('email').notNull(),
emailVerified: timestamp('emailVerified', { mode: 'string' }),
image: text('image'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export const accounts = pgTable('accounts', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
export const accounts = pgTable('account', {
userId: text('userId').notNull().references(() => users.id, { onDelete: 'cascade' }),
type: text('type').notNull(),
provider: text('provider').notNull(),
providerAccountId: text('provider_account_id').notNull(),
refreshToken: text('refresh_token'),
accessToken: text('access_token'),
expiresAt: timestamp('expires_at'),
tokenType: text('token_type'),
providerAccountId: text('providerAccountId').notNull(),
refresh_token: text('refresh_token'),
access_token: text('access_token'),
expires_at: text('expires_at'),
token_type: text('token_type'),
scope: text('scope'),
idToken: text('id_token'),
sessionState: text('session_state'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
id_token: text('id_token'),
session_state: text('session_state'),
}, (account) => ({
compoundKey: primaryKey({ columns: [account.provider, account.providerAccountId] })
}));
export const sessions = pgTable('session', {
sessionToken: text().primaryKey().notNull(),
userId: text().notNull().references(() => users.id, { onDelete: 'cascade' }),
expires: timestamp({ mode: 'string' }).notNull(),
});
export const sessions = pgTable('sessions', {
id: uuid('id').primaryKey().defaultRandom(),
sessionToken: text('session_token').notNull().unique(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
expires: timestamp('expires').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export const verificationTokens = pgTable('verificationToken', {
identifier: text('identifier').notNull(),
token: text('token').notNull(),
expires: timestamp('expires', { mode: 'string' }).notNull(),
}, (vt) => ({
compoundKey: primaryKey({ columns: [vt.identifier, vt.token] })
}));
export const authenticators = pgTable('authenticator', {
credentialID: text('credentialID').notNull().unique(),
userId: text('userId').notNull().references(() => users.id, { onDelete: 'cascade' }),
providerAccountId: text('providerAccountId').notNull(),
credentialPublicKey: text('credentialPublicKey').notNull(),
counter: integer('counter').notNull(),
credentialDeviceType: text('credentialDeviceType').notNull(),
credentialBackedUp: boolean('credentialBackedUp').notNull(),
transports: text('transports'),
}, (authenticator) => ({
compositePK: primaryKey({
columns: [authenticator.credentialID, authenticator.userId],
name: "authenticator_userId_credentialID_pk"
})
}));

View File

@@ -1,25 +0,0 @@
import { auth } from "@/auth"
export default auth((req) => {
const { nextUrl } = req
const isLoggedIn = !!req.auth
// Protect dashboard routes
// if (nextUrl.pathname.startsWith('/api') && !isLoggedIn) {
// return Response.redirect(new URL('/signin', nextUrl))
// }
// Redirect logged-in users from sign-in page
if (nextUrl.pathname.startsWith('/signin') && isLoggedIn) {
return Response.redirect(new URL('/', nextUrl))
}
})
export const config = {
matcher: [
// Skip Next.js internals and all static files
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
],
}