working on sign in and onboarding

This commit is contained in:
Khalim Conn-Kowlessar 2025-10-13 08:52:34 +00:00
parent 4bf3ec44f2
commit 7d51fcc4dc
14 changed files with 4348 additions and 131 deletions

165
package-lock.json generated
View file

@ -38,8 +38,7 @@
"aws-sdk": "^2.1415.0",
"class-variance-authority": "^0.6.1",
"clsx": "^1.2.1",
"drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.3",
"drizzle-orm": "^0.44.5",
"esbuild": "^0.25.8",
"eslint-config-next": "13.4.3",
"lucide-react": "^0.233.0",
@ -47,6 +46,7 @@
"next-auth": "^4.22.1",
"next-axiom": "^1.9.2",
"next-themes": "^0.3.0",
"nodemailer": "^6.10.1",
"pg": "^8.11.1",
"postcss": "^8.5.6",
"react": "18.3.1",
@ -68,6 +68,7 @@
"cypress": "^14.5.3",
"cypress-social-logins": "^1.14.1",
"dotenv": "^16.3.1",
"drizzle-kit": "^0.31.5",
"eslint": "^8.57.1",
"prettier": "^3.6.2",
"start-server-and-test": "^2.0.0"
@ -85,6 +86,95 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@auth/core": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.2.tgz",
"integrity": "sha512-KywHKRgLiF3l7PLyL73fjLSIBe1YNcA6sMeew4yMP6cfCWGXZrkkXd32AjRi1hlJ9nvovUBGZHvbn+LijO6ZeQ==",
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"@panva/hkdf": "^1.1.1",
"@types/cookie": "0.6.0",
"cookie": "0.6.0",
"jose": "^5.1.3",
"oauth4webapi": "^2.10.4",
"preact": "10.11.3",
"preact-render-to-string": "5.2.3"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"nodemailer": "^6.8.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@auth/core/node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@auth/core/node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@auth/core/node_modules/preact": {
"version": "10.11.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/@auth/core/node_modules/preact-render-to-string": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz",
"integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"pretty-format": "^3.8.0"
},
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/@auth/core/node_modules/pretty-format": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@aws-crypto/sha256-browser": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
@ -826,6 +916,7 @@
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
"integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@emnapi/core": {
@ -864,6 +955,7 @@
"resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz",
"integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==",
"deprecated": "Merged into tsx: https://tsx.is",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.18.20",
@ -877,6 +969,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -893,6 +986,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -909,6 +1003,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -925,6 +1020,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -941,6 +1037,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -957,6 +1054,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -973,6 +1071,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -989,6 +1088,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -1005,6 +1105,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -1021,6 +1122,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -1037,6 +1139,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -1053,6 +1156,7 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -1069,6 +1173,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -1085,6 +1190,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -1101,6 +1207,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -1117,6 +1224,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -1133,6 +1241,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -1149,6 +1258,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -1165,6 +1275,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -1181,6 +1292,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -1197,6 +1309,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -1213,6 +1326,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -1226,6 +1340,7 @@
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
"integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@ -1264,6 +1379,7 @@
"resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz",
"integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==",
"deprecated": "Merged into tsx: https://tsx.is",
"dev": true,
"license": "MIT",
"dependencies": {
"@esbuild-kit/core-utils": "^3.3.2",
@ -5470,6 +5586,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
@ -6849,6 +6973,7 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cachedir": {
@ -7879,9 +8004,10 @@
}
},
"node_modules/drizzle-kit": {
"version": "0.31.4",
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.4.tgz",
"integrity": "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA==",
"version": "0.31.5",
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.5.tgz",
"integrity": "sha512-+CHgPFzuoTQTt7cOYCV6MOw2w8vqEn/ap1yv4bpZOWL03u7rlVRQhUY0WYT3rHsgVTXwYQDZaSUJSQrMBUKuWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@drizzle-team/brocli": "^0.10.2",
@ -7894,9 +8020,9 @@
}
},
"node_modules/drizzle-orm": {
"version": "0.44.4",
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.44.4.tgz",
"integrity": "sha512-ZyzKFpTC/Ut3fIqc2c0dPZ6nhchQXriTsqTNs4ayRgl6sZcFlMs9QZKPSHXK4bdOf41GHGWf+FrpcDDYwW+W6Q==",
"version": "0.44.5",
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.44.5.tgz",
"integrity": "sha512-jBe37K7d8ZSKptdKfakQFdeljtu3P2Cbo7tJoJSVZADzIKOBo9IAJPOmMsH2bZl90bZgh8FQlD8BjxXA/zuBkQ==",
"license": "Apache-2.0",
"peerDependencies": {
"@aws-sdk/client-rds-data": ">=3",
@ -8333,6 +8459,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz",
"integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.4"
@ -11150,6 +11277,15 @@
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@ -11187,6 +11323,17 @@
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
"license": "MIT"
},
"node_modules/oauth4webapi": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz",
"integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==",
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -13044,6 +13191,7 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@ -13062,6 +13210,7 @@
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",

View file

@ -44,8 +44,7 @@
"aws-sdk": "^2.1415.0",
"class-variance-authority": "^0.6.1",
"clsx": "^1.2.1",
"drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.3",
"drizzle-orm": "^0.44.5",
"esbuild": "^0.25.8",
"eslint-config-next": "13.4.3",
"lucide-react": "^0.233.0",
@ -53,6 +52,7 @@
"next-auth": "^4.22.1",
"next-axiom": "^1.9.2",
"next-themes": "^0.3.0",
"nodemailer": "^6.10.1",
"pg": "^8.11.1",
"postcss": "^8.5.6",
"react": "18.3.1",
@ -74,6 +74,7 @@
"cypress": "^14.5.3",
"cypress-social-logins": "^1.14.1",
"dotenv": "^16.3.1",
"drizzle-kit": "^0.31.5",
"eslint": "^8.57.1",
"prettier": "^3.6.2",
"start-server-and-test": "^2.0.0"

View file

@ -0,0 +1,276 @@
import { eq, and } from "drizzle-orm";
import type {
Adapter,
AdapterUser,
AdapterAccount,
VerificationToken,
} from "next-auth/adapters";
import {
user as userTable,
accounts as accountsTable,
sessions as sessionsTable,
verificationTokens as verificationTokensTable,
} from "@/app/db/schema/users";
/**
* Custom Drizzle adapter for NextAuth v4
* ---------------------------------------
* Works with bigint user IDs (no need for UUID migration)
* Compatible with existing users table
* Adds optional Database Session support
*
* By default, NextAuth uses JWT-based sessions (stateless).
* The session functions here are only used if you enable:
*
* session: { strategy: "database" }
*
* Benefits of database sessions:
* - Revocable sessions (logout from all devices)
* - View active sessions per user
* - Force re-login after role/permission changes
* - Audit trail of login activity
*/
export default function DrizzleEmailAdapter(
db: any,
tables: {
user: typeof userTable;
accounts: typeof accountsTable;
verificationTokens: typeof verificationTokensTable;
sessions?: typeof sessionsTable;
}
): Adapter {
const { user, accounts, verificationTokens, sessions } = tables;
//----------------------------------------------------------------------
// Helpers
//----------------------------------------------------------------------
const normaliseEmail = (email: string) => email.trim().toLowerCase();
const toAdapterUser = (u: any): AdapterUser => ({
id: String(u.id),
dbId: String(u.id),
email: u.email,
name: u.firstName ?? null,
image: u.image ?? null,
emailVerified: u.emailVerified ?? null,
});
//----------------------------------------------------------------------
// Adapter methods
//----------------------------------------------------------------------
return {
//------------------------------------------------------------------
// USERS
//------------------------------------------------------------------
async createUser(
newUser: Omit<AdapterUser, "id"> & { id?: string }
): Promise<AdapterUser> {
const [created] = await db
.insert(user) // <-- now clearly the table
.values({
email: normaliseEmail(newUser.email!),
firstName: newUser.name ?? null,
image: newUser.image ?? null,
emailVerified: newUser.emailVerified ?? null,
})
.returning();
return toAdapterUser(created);
},
async getUser(id: string): Promise<AdapterUser | null> {
const [found] = await db
.select()
.from(user)
.where(eq(user.id, BigInt(id)));
return found ? toAdapterUser(found) : null;
},
async getUserByEmail(email: string): Promise<AdapterUser | null> {
const [found] = await db
.select()
.from(user)
.where(eq(user.email, normaliseEmail(email)));
return found ? toAdapterUser(found) : null;
},
async updateUser(
u: Partial<AdapterUser> & Pick<AdapterUser, "id">
): Promise<AdapterUser> {
const [updated] = await db
.update(user)
.set({
firstName: u.name ?? null,
image: u.image ?? null,
emailVerified: u.emailVerified ?? null,
})
.where(eq(user.id, BigInt(u.id)))
.returning();
return toAdapterUser(updated);
},
async deleteUser(id: string): Promise<void> {
await db.delete(user).where(eq(user.id, BigInt(id)));
},
//------------------------------------------------------------------
// ACCOUNTS (OAuth)
//------------------------------------------------------------------
async linkAccount(account: AdapterAccount): Promise<void> {
try {
await db.insert(accounts).values({
userId: BigInt(account.userId),
provider: account.provider,
providerAccountId: account.providerAccountId,
type: account.type,
refresh_token: account.refresh_token ?? null,
access_token: account.access_token ?? null,
expires_at: account.expires_at ?? null,
token_type: account.token_type ?? null,
scope: account.scope ?? null,
id_token: account.id_token ?? null,
session_state: account.session_state ?? null,
});
} catch (err: any) {
console.error("Error linking account:", err);
throw err;
}
},
async unlinkAccount(params: {
provider: string;
providerAccountId: string;
}): Promise<void> {
await db
.delete(accounts)
.where(
and(
eq(accounts.provider, params.provider),
eq(accounts.providerAccountId, params.providerAccountId)
)
);
},
async getUserByAccount(params: {
provider: string;
providerAccountId: string;
}): Promise<AdapterUser | null> {
const [acc] = await db
.select()
.from(accounts)
.where(
and(
eq(accounts.provider, params.provider),
eq(accounts.providerAccountId, params.providerAccountId)
)
);
if (!acc) return null;
const [usr] = await db
.select()
.from(user)
.where(eq(user.id, BigInt(acc.userId)));
return usr ? toAdapterUser(usr) : null;
},
//------------------------------------------------------------------
// EMAIL VERIFICATION TOKENS
//------------------------------------------------------------------
async createVerificationToken(
token: VerificationToken
): Promise<VerificationToken> {
const [created] = await db
.insert(verificationTokens)
.values({
...token,
expires: new Date(token.expires), // keep as Date
})
.returning();
return created as VerificationToken;
},
async useVerificationToken(params: {
identifier: string;
token: string;
}): Promise<VerificationToken | null> {
const [found] = await db
.select()
.from(verificationTokens)
.where(
and(
eq(verificationTokens.identifier, params.identifier),
eq(verificationTokens.token, params.token)
)
);
if (!found) return null;
await db
.delete(verificationTokens)
.where(
and(
eq(verificationTokens.identifier, params.identifier),
eq(verificationTokens.token, params.token)
)
);
return found as VerificationToken;
},
//------------------------------------------------------------------
// SESSIONS (Optional only used if session.strategy = "database")
//------------------------------------------------------------------
async createSession(session) {
if (!sessions) return null;
const [created] = await db
.insert(sessions)
.values({
sessionToken: session.sessionToken,
userId: BigInt(session.userId),
expires: session.expires,
})
.returning();
return created;
},
async getSessionAndUser(sessionToken) {
if (!sessions) return null;
const [session] = await db
.select()
.from(sessions)
.where(eq(sessions.sessionToken, sessionToken));
if (!session) return null;
const [u] = await db
.select()
.from(user)
.where(eq(user.id, BigInt(session.userId)));
if (!u) return null;
return {
session,
user: toAdapterUser(u),
};
},
async updateSession(session) {
if (!sessions) return null;
const [updated] = await db
.update(sessions)
.set({ expires: session.expires })
.where(eq(sessions.sessionToken, session.sessionToken))
.returning();
return updated ?? null;
},
async deleteSession(sessionToken) {
if (!sessions) return;
await db.delete(sessions).where(eq(sessions.sessionToken, sessionToken));
},
};
}

View file

@ -1,29 +1,48 @@
import NextAuth, { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import AzureADB2CProvider from "next-auth/providers/azure-ad-b2c";
import CredentialsProvider from "next-auth/providers/credentials";
import EmailProvider from "next-auth/providers/email";
import DrizzleEmailAdapter from "./DrizzleEmailAdapter";
import { db } from "@/app/db/db";
import { user as userTable, User } from "@/app/db/schema/users";
import {
user as users,
accounts,
verificationTokens,
} from "@/app/db/schema/users";
import { eq } from "drizzle-orm";
const { GOOGLE_CLIENT_ID = "", GOOGLE_CLIENT_SECRET = "" } = process.env;
// ------------------------------------------------------------------
// Environment variables
// ------------------------------------------------------------------
const {
GOOGLE_CLIENT_ID = "",
GOOGLE_CLIENT_SECRET = "",
AZURE_AD_B2C_TENANT_NAME = "",
AZURE_AD_B2C_CLIENT_ID = "",
AZURE_AD_B2C_CLIENT_SECRET = "",
AZURE_AD_B2C_PRIMARY_USER_FLOW = "",
EMAIL_SERVER_HOST = "",
EMAIL_SERVER_PORT = "",
EMAIL_SERVER_USER = "",
EMAIL_SERVER_PASSWORD = "",
EMAIL_FROM = "",
} = process.env;
type OauthProvider = "google";
// TODO: handle token expiration
// https://next-auth.js.org/v3/tutorials/refresh-token-rotation
// propertly set options too
// https://next-auth.js.org/configuration/options
type OauthProvider = "google" | "azure-ad-b2c";
// ------------------------------------------------------------------
// NextAuth configuration
// ------------------------------------------------------------------
export const AuthOptions: NextAuthOptions = {
adapter: DrizzleEmailAdapter(db, {
user: users,
accounts,
verificationTokens,
}),
providers: [
// ------------------ Google ------------------
GoogleProvider({
clientId: GOOGLE_CLIENT_ID,
clientSecret: GOOGLE_CLIENT_SECRET,
@ -35,6 +54,8 @@ export const AuthOptions: NextAuthOptions = {
},
},
}),
// ------------------ Azure AD B2C ------------------
AzureADB2CProvider({
tenantId: AZURE_AD_B2C_TENANT_NAME,
clientId: AZURE_AD_B2C_CLIENT_ID,
@ -47,109 +68,124 @@ export const AuthOptions: NextAuthOptions = {
},
},
}),
CredentialsProvider({
name: "Email Login",
credentials: {
email: {
label: "Email",
type: "email",
// ------------------ Email (SES Magic Link) ------------------
EmailProvider({
server: {
host: EMAIL_SERVER_HOST,
port: Number(EMAIL_SERVER_PORT),
auth: {
user: EMAIL_SERVER_USER,
pass: EMAIL_SERVER_PASSWORD,
},
},
async authorize(credentials, req) {
if (!credentials || !credentials.email) {
throw new Error("Email is required");
}
const { email } = credentials;
// Query the database to find the user by email
const dbUser = await db
.select()
.from(userTable)
.where(eq(userTable.email, email));
// If the email exists, return the user object (no password check)
if (dbUser.length === 1) {
return {
id: dbUser[0].id.toString(), // Convert bigint to string to avoid serialization issues
email: dbUser[0].email,
dbId: dbUser[0].id.toString(), // Ensure dbId is added and is a string
};
}
return null;
},
from: EMAIL_FROM,
maxAge: 60 * 60, // magic link valid for 1 hour
}),
],
// ------------------------------------------------------------------
// Pages
// ------------------------------------------------------------------
pages: {
signIn: "/",
signIn: "/", // your landing/login page
verifyRequest: "/auth/verify-request",
error: "/auth/error",
},
// ------------------------------------------------------------------
// Callbacks
// ------------------------------------------------------------------
callbacks: {
/**
* Sign in callback ensures user exists and links OAuth provider if needed
*/
async signIn({ user, account }) {
try {
if (user === null || user.email === null) {
return "/beta";
}
const dbUser: User[] = await db
.select()
.from(userTable)
.where(eq(userTable.email, String(user.email)));
if (!user?.email) return false;
if (dbUser.length > 1) {
console.error(`Multiple users found with email ${user.email}`);
const normalisedEmail = user.email.toLowerCase();
// Fetch the user (NextAuth will have created them already if new)
const [dbUser] = await db
.select()
.from(users)
.where(eq(users.email, normalisedEmail));
if (!dbUser) {
console.warn(`User not found for ${normalisedEmail} after sign-in.`);
return false;
}
if (dbUser.length === 0 || account === null) {
return "/beta";
}
if (!dbUser[0].oauthId) {
// We make a second query to populate the oauthId and oauthProvider
console.log("Updating user with oauthId and oauthProvider");
// Link OAuth ID if missing (helps for older accounts)
if (account && !dbUser.oauthId) {
const provider = account.provider as OauthProvider;
await db
.update(userTable)
.update(users)
.set({ oauthId: user.id, oauthProvider: provider })
.where(eq(userTable.email, String(user.email)));
console.log("Updated oauthId and oauthProvider");
.where(eq(users.email, normalisedEmail));
}
// Always update last login timestamp
await db
.update(users)
.set({ lastLogin: new Date() })
.where(eq(users.id, dbUser.id));
// Pass bigint ID into NextAuth session/jwt
user.dbId = dbUser.id.toString();
// If the user isn't onboarded yet, redirect to onboarding
if (!dbUser.onboarded) {
return "/onboarding";
}
// Set the user's ID from your database
// Because bigint isn't serializable, we need to convert it to a string
user.dbId = dbUser[0].id.toString();
return true;
} catch (error) {
console.error("Error during sign-in: ", error);
console.error("Error during sign-in:", error);
return false;
}
},
/**
* Persist dbId in the JWT so its available in sessions
*/
async jwt({ token, user }) {
// This is executed whenever a JWT is created or refreshed.
// `user` is the object returned from `signIn` callback and
// is only available during sign in, which is why we need to
// store the id in the token and then read it back into the session.
if (user?.dbId) {
token.dbId = user.dbId;
}
return token;
},
/**
* Attach dbId to session.user
*/
async session({ session, token }) {
if (session?.user) {
if (session.user && token.dbId) {
session.user.dbId = token.dbId;
}
return session;
},
/**
* Redirect users after login
*/
async redirect({ baseUrl }) {
const redirectUrl = baseUrl + "/home";
return redirectUrl;
return `${baseUrl}/home`;
},
},
// ------------------------------------------------------------------
// General options
// ------------------------------------------------------------------
session: {
strategy: "jwt", // lightweight, avoids DB session writes
},
jwt: {
maxAge: 60 * 60 * 24 * 30, // 30 days
},
debug: false,
};
const handler = NextAuth(AuthOptions);
export { handler as GET, handler as POST };

View file

@ -1,6 +1,6 @@
import { db } from "@/app/db/db";
import { NextRequest, NextResponse } from "next/server";
import { portfolio, portfolioUsers} from "@/app/db/schema/portfolio";
import { portfolio, portfolioUsers } from "@/app/db/schema/portfolio";
import { user } from "@/app/db/schema/users";
import {
recommendation,
@ -18,7 +18,6 @@ import { eq, inArray, Name } from "drizzle-orm";
import { z } from "zod";
import { ROLE_OPTIONS } from "@/app/portfolio/[slug]/(portfolio)/settings/roles";
// Get colloborators (users) that have access to the portfolio
export async function GET(
_req: NextRequest,
@ -41,7 +40,7 @@ export async function GET(
// Explicitly normalize BigInts to strings
const collaborators = rows.map((r) => ({
portfolioUserId: r.portfolioUserId ? r.portfolioUserId.toString(): null,
portfolioUserId: r.portfolioUserId ? r.portfolioUserId.toString() : null,
userId: r.userId ? r.userId.toString() : null,
role: r.role,
name: r.name ?? null,
@ -58,7 +57,6 @@ export async function GET(
}
}
// PUT: update a collaborators role
export async function PUT(
req: NextRequest,
@ -110,7 +108,7 @@ export async function POST(
const bodySchema = z.object({
email: z.string().email(),
role: z.enum(ROLE_OPTIONS),
name: z.string()
name: z.string(),
});
let body: z.infer<typeof bodySchema>;
@ -138,10 +136,14 @@ export async function POST(
// If youre on Postgres, this is ideal:
const inserted = await db
.insert(user)
.values({ email: body.email, firstName: body.name, oauthProvider: "credentials" })
.values({
email: body.email,
firstName: body.name,
oauthProvider: "credentials",
})
.onConflictDoNothing() // relies on a UNIQUE(email) constraint
.returning({ id: user.id });
if (inserted.length > 0) {
createdUserId = inserted[0].id;
} else {
@ -202,4 +204,4 @@ export async function POST(
{ status: 500 }
);
}
}
}

View file

@ -0,0 +1,14 @@
export default function VerifyRequestPage() {
return (
<div className="flex h-screen items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-2xl font-semibold text-brandblue mb-2">
Check your email
</h1>
<p className="text-gray-600">
Weve sent you a sign-in link. Click it to finish logging in.
</p>
</div>
</div>
);
}

View file

@ -1,3 +0,0 @@
export default function Beta() {
return <div>You do not have access to this application currently</div>;
}

View file

@ -13,17 +13,20 @@ export default function EmailSignInButton({
}) {
const [email, setEmail] = useState("");
const [error, setError] = useState(initialError);
const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
const handleSubmit = async (e: { preventDefault: () => void }) => {
e.preventDefault();
const res = await signIn("credentials", {
email,
});
setStatus("sending");
const res = await signIn("email", { email, redirect: false });
if (res?.error) {
setError("You are not a valid user.");
setStatus("idle");
} else {
console.log("Login successful");
setError(undefined);
setStatus("sent");
}
};
@ -31,40 +34,42 @@ export default function EmailSignInButton({
target: { value: SetStateAction<string> };
}) => {
setEmail(e.target.value);
if (error) {
setError(undefined); // Clear the error when the user starts typing
}
if (error) setError(undefined);
};
// Sync initial error state with server-side error prop
useEffect(() => {
setError(initialError);
}, [initialError]);
// Keep server-side error synced
useEffect(() => setError(initialError), [initialError]);
return (
<form onSubmit={handleSubmit} className="w-full">
{/* Wrapper to control width and layout */}
<div className="flex items-center w-full space-x-1">
{/* Email input field using shadcn input */}
<Input
type="email"
value={email}
onChange={handleEmailChange}
placeholder="Enter your email"
required
className="flex-1 h-10 rounded-lg border-gray-300" // Full width input
className="flex-1 h-10 rounded-lg border-gray-300"
/>
<Button
type="submit"
className="h-10 w-10 bg-brandblue text-white hover:bg-hoverblue rounded-lg flex items-center justify-center" // Fixed size button
disabled={status === "sending"}
className="h-10 w-10 bg-brandblue text-white hover:bg-hoverblue rounded-lg flex items-center justify-center"
>
<ChevronRightIcon className="h-5 w-5" />
</Button>
</div>
{/* Reserve space for the error message */}
<div className="min-h-[3rem] text-center">
{error && <p className="text-red-500">You are not a valid user.</p>}
{error && <p className="text-red-500">{error}</p>}
{status === "sent" && (
<p className="text-green-600">
A login link has been sent to your email.
</p>
)}
{status === "sending" && (
<p className="text-gray-500">Sending login link...</p>
)}
</div>
</form>
);

View file

@ -0,0 +1,35 @@
CREATE TABLE "account" (
"userId" bigint NOT NULL,
"type" text NOT NULL,
"provider" text NOT NULL,
"providerAccountId" text NOT NULL,
"refresh_token" text,
"access_token" text,
"expires_at" integer,
"token_type" text,
"scope" text,
"id_token" text,
"session_state" text,
CONSTRAINT "account_provider_providerAccountId_pk" PRIMARY KEY("provider","providerAccountId")
);
--> statement-breakpoint
CREATE TABLE "session" (
"sessionToken" text PRIMARY KEY NOT NULL,
"userId" bigint NOT NULL,
"expires" timestamp NOT NULL
);
--> 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
ALTER TABLE "user" ADD COLUMN "emailVerified" timestamp;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "image" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "onboarded" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "last_login" timestamp;--> 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;--> 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 "user" ADD CONSTRAINT "user_email_unique" UNIQUE("email");

File diff suppressed because it is too large Load diff

View file

@ -820,6 +820,13 @@
"when": 1759069966418,
"tag": "0116_spotty_leech",
"breakpoints": true
},
{
"idx": 117,
"version": "7",
"when": 1760191704756,
"tag": "0117_colossal_bastion",
"breakpoints": true
}
]
}

View file

@ -1,27 +1,87 @@
import { bigserial, text, timestamp, pgTable } from "drizzle-orm/pg-core";
import {
bigint,
bigserial,
text,
timestamp,
pgTable,
primaryKey,
integer,
boolean,
} from "drizzle-orm/pg-core";
import { InferModel } from "drizzle-orm";
// -------------------------
// USERS
// -------------------------
export const user = pgTable("user", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
firstName: text("firstName"),
// At the moment, Drizzle doesn't support unique constraints
email: text("email").notNull(),
email: text("email").notNull().unique(),
emailVerified: timestamp("emailVerified", { mode: "date" }),
oauthId: text("oauth_id"),
oauthProvider: text("oauth_provider").$type<"google" | "credentials">(),
// role: text("role").$type<"admin" | "write" | "read">(),
createdAt: timestamp("created_at", {
precision: 6,
withTimezone: true,
})
oauthProvider: text("oauth_provider").$type<
"google" | "credentials" | "azure-ad-b2c"
>(),
image: text("image"),
onboarded: boolean("onboarded").default(false).notNull(),
lastLogin: timestamp("last_login", { mode: "date" }),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", {
precision: 6,
withTimezone: true,
})
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
});
// -------------------------
// ACCOUNTS (OAuth providers)
// -------------------------
export const accounts = pgTable(
"account",
{
userId: bigint("userId", { mode: "bigint" })
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
type: text("type").$type<"oauth" | "email" | "credentials">().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
(account) => [
primaryKey({ columns: [account.provider, account.providerAccountId] }),
]
);
export const sessions = pgTable("session", {
sessionToken: text("sessionToken").primaryKey(),
userId: bigint("userId", { mode: "bigint" })
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
expires: timestamp("expires", { mode: "date" }).notNull(),
});
export const verificationTokens = pgTable(
"verificationToken",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
},
(vt) => [primaryKey({ columns: [vt.identifier, vt.token] })]
);
// -------------------------
// Types
// -------------------------
export type User = InferModel<typeof user, "select">;
export type NewUser = InferModel<typeof user, "insert">;
export type Account = InferModel<typeof accounts, "select">;
export type Session = InferModel<typeof sessions, "select">;
export type VerificationToken = InferModel<typeof verificationTokens, "select">;

View file

@ -0,0 +1,50 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/app/shadcn_components/ui/button";
import { Input } from "@/app/shadcn_components/ui/input";
export default function OnboardingPage() {
const router = useRouter();
const [name, setName] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
await fetch("/api/user/onboard", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
router.push("/home");
}
return (
<div className="flex h-screen items-center justify-center bg-gray-50">
<form
onSubmit={handleSubmit}
className="p-8 bg-white rounded-xl shadow-md w-full max-w-md space-y-4"
>
<h1 className="text-xl font-semibold text-brandblue">Welcome!</h1>
<p className="text-gray-600">
Let's complete your profile to get started.
</p>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your full name"
required
/>
<Button
type="submit"
className="w-full bg-brandblue hover:bg-hoverblue"
>
Continue
</Button>
</form>
</div>
);
}

View file

@ -2,15 +2,13 @@ import { getServerSession } from "next-auth/next";
import { AuthOptions } from "./api/auth/[...nextauth]/route";
import GoogleSignInButton from "./components/signin/GoogleSignInButton";
import MicrosoftSignInButton from "./components/signin/MicrosoftSignInButton";
import EmailSignInButton from "./components/signin/CredentialsButton";
import EmailSignInButton from "./components/signin/EmailSignInButton";
import { redirect } from "next/navigation";
import Image from "next/image";
export default async function Home(
props: {
searchParams: Promise<{ error?: string }>;
}
) {
export default async function Home(props: {
searchParams: Promise<{ error?: string }>;
}) {
const searchParams = await props.searchParams;
const session = await getServerSession(AuthOptions);