aboutsummaryrefslogtreecommitdiff
path: root/packages/web
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-03 21:07:28 -0800
committerFuwn <[email protected]>2026-02-03 21:07:28 -0800
commite72b0cb261b5fc9c70a839882ea07160ef7ef424 (patch)
tree0913ca6b24a078b91a64d15817d80d3cdaf56d32 /packages/web
parentfeat(mcp): Wire to Supabase with project and search tools (diff)
downloadarchived-imemio-e72b0cb261b5fc9c70a839882ea07160ef7ef424.tar.xz
archived-imemio-e72b0cb261b5fc9c70a839882ea07160ef7ef424.zip
feat(web): Replace NextAuth with Supabase Auth
Diffstat (limited to 'packages/web')
-rw-r--r--packages/web/.env.example13
-rw-r--r--packages/web/biome.jsonc69
-rw-r--r--packages/web/package.json4
-rw-r--r--packages/web/src/app/api/auth/[...nextauth]/route.ts3
-rw-r--r--packages/web/src/app/api/auth/callback/route.ts16
-rw-r--r--packages/web/src/app/auth/sign-in/page.tsx88
-rw-r--r--packages/web/src/app/auth/sign-up/page.tsx104
-rw-r--r--packages/web/src/env.js14
-rw-r--r--packages/web/src/lib/supabase/client.ts9
-rw-r--r--packages/web/src/lib/supabase/server.ts24
-rw-r--r--packages/web/src/server/api/routers/post.ts2
-rw-r--r--packages/web/src/server/api/trpc.ts13
-rw-r--r--packages/web/src/server/auth/config.ts66
-rw-r--r--packages/web/src/server/auth/index.ts21
-rw-r--r--packages/web/src/server/db/schema.ts80
15 files changed, 277 insertions, 249 deletions
diff --git a/packages/web/.env.example b/packages/web/.env.example
index 4347a30..72edc6d 100644
--- a/packages/web/.env.example
+++ b/packages/web/.env.example
@@ -9,15 +9,10 @@
# When adding additional environment variables, the schema in "/src/env.js"
# should be updated accordingly.
-# Next Auth
-# You can generate a new secret on the command line with:
-# npx auth secret
-# https://next-auth.js.org/configuration/options#secret
-AUTH_SECRET=""
-
-# Next Auth Discord Provider
-AUTH_DISCORD_ID=""
-AUTH_DISCORD_SECRET=""
+# Supabase
+# Get these from your Supabase project settings
+NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co"
+NEXT_PUBLIC_SUPABASE_ANON_KEY="your-anon-key"
# Drizzle
DATABASE_URL="postgresql://postgres:password@localhost:5432/web"
diff --git a/packages/web/biome.jsonc b/packages/web/biome.jsonc
deleted file mode 100644
index b1581db..0000000
--- a/packages/web/biome.jsonc
+++ /dev/null
@@ -1,69 +0,0 @@
-{
- "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
- "root": true,
- "vcs": {
- "enabled": true,
- "useIgnoreFile": true,
- "clientKind": "git"
- },
- "assist": {
- "enabled": true,
- "actions": {
- "recommended": true,
- "source": {
- "recommended": true,
- "organizeImports": "on",
- "useSortedAttributes": "on"
- }
- }
- },
- "formatter": {
- "enabled": true
- },
- "linter": {
- "enabled": true,
- "rules": {
- "recommended": true,
- "nursery": {
- "useSortedClasses": {
- "level": "warn",
- "fix": "safe",
- "options": {
- "functions": ["clsx", "cva", "cn"]
- }
- }
- }
- }
- },
- "html": {
- "formatter": {
- "enabled": true
- }
- },
- "javascript": {
- "assist": {
- "enabled": true
- },
- "formatter": {
- "enabled": true
- },
- "linter": {
- "enabled": true
- }
- },
- "css": {
- "assist": {
- "enabled": true
- },
- "formatter": {
- "enabled": true
- },
- "linter": {
- "enabled": true
- },
- "parser": {
- "cssModules": true,
- "tailwindDirectives": true
- }
- }
-}
diff --git a/packages/web/package.json b/packages/web/package.json
index 2fbbca1..b140111 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -18,7 +18,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "@auth/drizzle-adapter": "^1.7.2",
+ "@supabase/ssr": "^0.8.0",
+ "@supabase/supabase-js": "^2.94.0",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.69.0",
"@trpc/client": "^11.0.0",
@@ -26,7 +27,6 @@
"@trpc/server": "^11.0.0",
"drizzle-orm": "^0.41.0",
"next": "^15.2.3",
- "next-auth": "5.0.0-beta.25",
"postgres": "^3.4.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
diff --git a/packages/web/src/app/api/auth/[...nextauth]/route.ts b/packages/web/src/app/api/auth/[...nextauth]/route.ts
deleted file mode 100644
index 8e8302c..0000000
--- a/packages/web/src/app/api/auth/[...nextauth]/route.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import { handlers } from "~/server/auth";
-
-export const { GET, POST } = handlers;
diff --git a/packages/web/src/app/api/auth/callback/route.ts b/packages/web/src/app/api/auth/callback/route.ts
new file mode 100644
index 0000000..3d02db4
--- /dev/null
+++ b/packages/web/src/app/api/auth/callback/route.ts
@@ -0,0 +1,16 @@
+import { NextResponse } from "next/server";
+import { createClient } from "~/lib/supabase/server";
+
+export async function GET(request: Request) {
+ const requestUrl = new URL(request.url);
+ const code = requestUrl.searchParams.get("code");
+ const origin = requestUrl.origin;
+
+ if (code) {
+ const supabase = await createClient();
+
+ await supabase.auth.exchangeCodeForSession(code);
+ }
+
+ return NextResponse.redirect(`${origin}/`);
+}
diff --git a/packages/web/src/app/auth/sign-in/page.tsx b/packages/web/src/app/auth/sign-in/page.tsx
new file mode 100644
index 0000000..3c4c10a
--- /dev/null
+++ b/packages/web/src/app/auth/sign-in/page.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { useState } from "react";
+import { createClient } from "~/lib/supabase/client";
+
+export default function SignInPage() {
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState<string | null>(null);
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ setError(null);
+ setLoading(true);
+
+ const supabase = createClient();
+ const { error } = await supabase.auth.signInWithPassword({
+ email,
+ password,
+ });
+
+ setLoading(false);
+
+ if (error) {
+ setError(error.message);
+
+ return;
+ }
+
+ window.location.href = "/";
+ }
+
+ return (
+ <div className="flex min-h-screen items-center justify-center">
+ <div className="w-full max-w-md space-y-8 p-8">
+ <h1 className="text-center text-2xl font-bold">Sign In</h1>
+
+ <form className="space-y-6" onSubmit={handleSubmit}>
+ <div>
+ <label className="block text-sm font-medium" htmlFor="email">
+ Email
+ </label>
+ <input
+ className="mt-1 block w-full rounded-md border px-3 py-2"
+ id="email"
+ onChange={(event) => setEmail(event.target.value)}
+ required
+ type="email"
+ value={email}
+ />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium" htmlFor="password">
+ Password
+ </label>
+ <input
+ className="mt-1 block w-full rounded-md border px-3 py-2"
+ id="password"
+ onChange={(event) => setPassword(event.target.value)}
+ required
+ type="password"
+ value={password}
+ />
+ </div>
+
+ {error && <p className="text-sm text-red-500">{error}</p>}
+
+ <button
+ className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
+ disabled={loading}
+ type="submit"
+ >
+ {loading ? "Signing in ..." : "Sign In"}
+ </button>
+ </form>
+
+ <p className="text-center text-sm">
+ Don&apos;t have an account?{" "}
+ <a className="text-blue-600 hover:underline" href="/auth/sign-up">
+ Sign Up
+ </a>
+ </p>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/web/src/app/auth/sign-up/page.tsx b/packages/web/src/app/auth/sign-up/page.tsx
new file mode 100644
index 0000000..94a501c
--- /dev/null
+++ b/packages/web/src/app/auth/sign-up/page.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { useState } from "react";
+import { createClient } from "~/lib/supabase/client";
+
+export default function SignUpPage() {
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState<string | null>(null);
+ const [loading, setLoading] = useState(false);
+ const [success, setSuccess] = useState(false);
+
+ async function handleSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ setError(null);
+ setLoading(true);
+
+ const supabase = createClient();
+ const { error } = await supabase.auth.signUp({
+ email,
+ password,
+ options: {
+ emailRedirectTo: `${window.location.origin}/api/auth/callback`,
+ },
+ });
+
+ setLoading(false);
+
+ if (error) {
+ setError(error.message);
+
+ return;
+ }
+
+ setSuccess(true);
+ }
+
+ if (success) {
+ return (
+ <div className="flex min-h-screen items-center justify-center">
+ <div className="w-full max-w-md space-y-8 p-8 text-center">
+ <h1 className="text-2xl font-bold">Check your email</h1>
+ <p>We&apos;ve sent you a confirmation link to {email}</p>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="flex min-h-screen items-center justify-center">
+ <div className="w-full max-w-md space-y-8 p-8">
+ <h1 className="text-center text-2xl font-bold">Sign Up</h1>
+
+ <form className="space-y-6" onSubmit={handleSubmit}>
+ <div>
+ <label className="block text-sm font-medium" htmlFor="email">
+ Email
+ </label>
+ <input
+ className="mt-1 block w-full rounded-md border px-3 py-2"
+ id="email"
+ onChange={(event) => setEmail(event.target.value)}
+ required
+ type="email"
+ value={email}
+ />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium" htmlFor="password">
+ Password
+ </label>
+ <input
+ className="mt-1 block w-full rounded-md border px-3 py-2"
+ id="password"
+ minLength={6}
+ onChange={(event) => setPassword(event.target.value)}
+ required
+ type="password"
+ value={password}
+ />
+ </div>
+
+ {error && <p className="text-sm text-red-500">{error}</p>}
+
+ <button
+ className="w-full rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
+ disabled={loading}
+ type="submit"
+ >
+ {loading ? "Creating account ..." : "Sign Up"}
+ </button>
+ </form>
+
+ <p className="text-center text-sm">
+ Already have an account?{" "}
+ <a className="text-blue-600 hover:underline" href="/auth/sign-in">
+ Sign In
+ </a>
+ </p>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/web/src/env.js b/packages/web/src/env.js
index 0a23897..5779b5f 100644
--- a/packages/web/src/env.js
+++ b/packages/web/src/env.js
@@ -7,12 +7,6 @@ export const env = createEnv({
* isn't built with invalid env vars.
*/
server: {
- AUTH_SECRET:
- process.env.NODE_ENV === "production"
- ? z.string()
- : z.string().optional(),
- AUTH_DISCORD_ID: z.string(),
- AUTH_DISCORD_SECRET: z.string(),
DATABASE_URL: z.string().url(),
NODE_ENV: z
.enum(["development", "test", "production"])
@@ -25,7 +19,8 @@ export const env = createEnv({
* `NEXT_PUBLIC_`.
*/
client: {
- // NEXT_PUBLIC_CLIENTVAR: z.string(),
+ NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
+ NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string(),
},
/**
@@ -33,11 +28,10 @@ export const env = createEnv({
* middlewares) or client-side so we need to destruct manually.
*/
runtimeEnv: {
- AUTH_SECRET: process.env.AUTH_SECRET,
- AUTH_DISCORD_ID: process.env.AUTH_DISCORD_ID,
- AUTH_DISCORD_SECRET: process.env.AUTH_DISCORD_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
+ NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
+ NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
diff --git a/packages/web/src/lib/supabase/client.ts b/packages/web/src/lib/supabase/client.ts
new file mode 100644
index 0000000..d4d37f6
--- /dev/null
+++ b/packages/web/src/lib/supabase/client.ts
@@ -0,0 +1,9 @@
+import { createBrowserClient } from "@supabase/ssr";
+import { env } from "~/env";
+
+export function createClient() {
+ return createBrowserClient(
+ env.NEXT_PUBLIC_SUPABASE_URL,
+ env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
+ );
+}
diff --git a/packages/web/src/lib/supabase/server.ts b/packages/web/src/lib/supabase/server.ts
new file mode 100644
index 0000000..86fa648
--- /dev/null
+++ b/packages/web/src/lib/supabase/server.ts
@@ -0,0 +1,24 @@
+import { createServerClient } from "@supabase/ssr";
+import { cookies } from "next/headers";
+import { env } from "~/env";
+
+export async function createClient() {
+ const cookieStore = await cookies();
+
+ return createServerClient(
+ env.NEXT_PUBLIC_SUPABASE_URL,
+ env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
+ {
+ cookies: {
+ getAll() {
+ return cookieStore.getAll();
+ },
+ setAll(cookiesToSet) {
+ for (const { name, value, options } of cookiesToSet) {
+ cookieStore.set(name, value, options);
+ }
+ },
+ },
+ },
+ );
+}
diff --git a/packages/web/src/server/api/routers/post.ts b/packages/web/src/server/api/routers/post.ts
index 2bd03a4..301e252 100644
--- a/packages/web/src/server/api/routers/post.ts
+++ b/packages/web/src/server/api/routers/post.ts
@@ -20,7 +20,7 @@ export const postRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
await ctx.db.insert(posts).values({
name: input.name,
- createdById: ctx.session.user.id,
+ createdById: ctx.user.id,
});
}),
diff --git a/packages/web/src/server/api/trpc.ts b/packages/web/src/server/api/trpc.ts
index 65c86f4..e4dd0ab 100644
--- a/packages/web/src/server/api/trpc.ts
+++ b/packages/web/src/server/api/trpc.ts
@@ -10,7 +10,7 @@
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
-import { auth } from "~/server/auth";
+import { getUser } from "~/server/auth";
import { db } from "~/server/db";
/**
@@ -26,11 +26,11 @@ import { db } from "~/server/db";
* @see https://trpc.io/docs/server/context
*/
export const createTRPCContext = async (opts: { headers: Headers }) => {
- const session = await auth();
+ const user = await getUser();
return {
db,
- session,
+ user,
...opts,
};
};
@@ -114,21 +114,20 @@ export const publicProcedure = t.procedure.use(timingMiddleware);
* Protected (authenticated) procedure
*
* If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies
- * the session is valid and guarantees `ctx.session.user` is not null.
+ * the user is authenticated and guarantees `ctx.user` is not null.
*
* @see https://trpc.io/docs/procedures
*/
export const protectedProcedure = t.procedure
.use(timingMiddleware)
.use(({ ctx, next }) => {
- if (!ctx.session?.user) {
+ if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
- // infers the `session` as non-nullable
- session: { ...ctx.session, user: ctx.session.user },
+ user: ctx.user,
},
});
});
diff --git a/packages/web/src/server/auth/config.ts b/packages/web/src/server/auth/config.ts
deleted file mode 100644
index b3307cc..0000000
--- a/packages/web/src/server/auth/config.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { DrizzleAdapter } from "@auth/drizzle-adapter";
-import type { DefaultSession, NextAuthConfig } from "next-auth";
-import DiscordProvider from "next-auth/providers/discord";
-import { db } from "~/server/db";
-import {
- accounts,
- sessions,
- users,
- verificationTokens,
-} from "~/server/db/schema";
-
-/**
- * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
- * object and keep type safety.
- *
- * @see https://next-auth.js.org/getting-started/typescript#module-augmentation
- */
-declare module "next-auth" {
- interface Session extends DefaultSession {
- user: {
- id: string;
- // ...other properties
- // role: UserRole;
- } & DefaultSession["user"];
- }
-
- // interface User {
- // // ...other properties
- // // role: UserRole;
- // }
-}
-
-/**
- * Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
- *
- * @see https://next-auth.js.org/configuration/options
- */
-export const authConfig = {
- providers: [
- DiscordProvider,
- /**
- * ...add more providers here.
- *
- * Most other providers require a bit more work than the Discord provider. For example, the
- * GitHub provider requires you to add the `refresh_token_expires_in` field to the Account
- * model. Refer to the NextAuth.js docs for the provider you want to use. Example:
- *
- * @see https://next-auth.js.org/providers/github
- */
- ],
- adapter: DrizzleAdapter(db, {
- usersTable: users,
- accountsTable: accounts,
- sessionsTable: sessions,
- verificationTokensTable: verificationTokens,
- }),
- callbacks: {
- session: ({ session, user }) => ({
- ...session,
- user: {
- ...session.user,
- id: user.id,
- },
- }),
- },
-} satisfies NextAuthConfig;
diff --git a/packages/web/src/server/auth/index.ts b/packages/web/src/server/auth/index.ts
index 21f0ee0..f94f4e4 100644
--- a/packages/web/src/server/auth/index.ts
+++ b/packages/web/src/server/auth/index.ts
@@ -1,8 +1,19 @@
-import NextAuth from "next-auth";
import { cache } from "react";
-import { authConfig } from "./config";
+import { createClient } from "~/lib/supabase/server";
-const { auth: uncachedAuth, handlers, signIn, signOut } = NextAuth(authConfig);
-const auth = cache(uncachedAuth);
+export const getUser = cache(async () => {
+ const supabase = await createClient();
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
-export { auth, handlers, signIn, signOut };
+ return user;
+});
+export const getSession = cache(async () => {
+ const supabase = await createClient();
+ const {
+ data: { session },
+ } = await supabase.auth.getSession();
+
+ return session;
+});
diff --git a/packages/web/src/server/db/schema.ts b/packages/web/src/server/db/schema.ts
index 08c1551..aae0ace 100644
--- a/packages/web/src/server/db/schema.ts
+++ b/packages/web/src/server/db/schema.ts
@@ -1,6 +1,4 @@
-import { relations } from "drizzle-orm";
-import { index, pgTableCreator, primaryKey } from "drizzle-orm/pg-core";
-import type { AdapterAccount } from "next-auth/adapters";
+import { index, pgTableCreator } from "drizzle-orm/pg-core";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
@@ -14,13 +12,10 @@ export const posts = createTable(
(d) => ({
id: d.integer().primaryKey().generatedByDefaultAsIdentity(),
name: d.varchar({ length: 256 }),
- createdById: d
- .varchar({ length: 255 })
- .notNull()
- .references(() => users.id),
+ createdById: d.varchar({ length: 255 }).notNull(),
createdAt: d
.timestamp({ withTimezone: true })
- .$defaultFn(() => /* @__PURE__ */ new Date())
+ .$defaultFn(() => new Date())
.notNull(),
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()),
}),
@@ -29,72 +24,3 @@ export const posts = createTable(
index("name_idx").on(t.name),
],
);
-export const users = createTable("user", (d) => ({
- id: d
- .varchar({ length: 255 })
- .notNull()
- .primaryKey()
- .$defaultFn(() => crypto.randomUUID()),
- name: d.varchar({ length: 255 }),
- email: d.varchar({ length: 255 }).notNull(),
- emailVerified: d
- .timestamp({
- mode: "date",
- withTimezone: true,
- })
- .$defaultFn(() => /* @__PURE__ */ new Date()),
- image: d.varchar({ length: 255 }),
-}));
-export const usersRelations = relations(users, ({ many }) => ({
- accounts: many(accounts),
-}));
-export const accounts = createTable(
- "account",
- (d) => ({
- userId: d
- .varchar({ length: 255 })
- .notNull()
- .references(() => users.id),
- type: d.varchar({ length: 255 }).$type<AdapterAccount["type"]>().notNull(),
- provider: d.varchar({ length: 255 }).notNull(),
- providerAccountId: d.varchar({ length: 255 }).notNull(),
- refresh_token: d.text(),
- access_token: d.text(),
- expires_at: d.integer(),
- token_type: d.varchar({ length: 255 }),
- scope: d.varchar({ length: 255 }),
- id_token: d.text(),
- session_state: d.varchar({ length: 255 }),
- }),
- (t) => [
- primaryKey({ columns: [t.provider, t.providerAccountId] }),
- index("account_user_id_idx").on(t.userId),
- ],
-);
-export const accountsRelations = relations(accounts, ({ one }) => ({
- user: one(users, { fields: [accounts.userId], references: [users.id] }),
-}));
-export const sessions = createTable(
- "session",
- (d) => ({
- sessionToken: d.varchar({ length: 255 }).notNull().primaryKey(),
- userId: d
- .varchar({ length: 255 })
- .notNull()
- .references(() => users.id),
- expires: d.timestamp({ mode: "date", withTimezone: true }).notNull(),
- }),
- (t) => [index("t_user_id_idx").on(t.userId)],
-);
-export const sessionsRelations = relations(sessions, ({ one }) => ({
- user: one(users, { fields: [sessions.userId], references: [users.id] }),
-}));
-export const verificationTokens = createTable(
- "verification_token",
- (d) => ({
- identifier: d.varchar({ length: 255 }).notNull(),
- token: d.varchar({ length: 255 }).notNull(),
- expires: d.timestamp({ mode: "date", withTimezone: true }).notNull(),
- }),
- (t) => [primaryKey({ columns: [t.identifier, t.token] })],
-);