diff options
Diffstat (limited to 'packages/web/src')
| -rw-r--r-- | packages/web/src/app/api/auth/[...nextauth]/route.ts | 3 | ||||
| -rw-r--r-- | packages/web/src/app/api/auth/callback/route.ts | 16 | ||||
| -rw-r--r-- | packages/web/src/app/auth/sign-in/page.tsx | 88 | ||||
| -rw-r--r-- | packages/web/src/app/auth/sign-up/page.tsx | 104 | ||||
| -rw-r--r-- | packages/web/src/env.js | 14 | ||||
| -rw-r--r-- | packages/web/src/lib/supabase/client.ts | 9 | ||||
| -rw-r--r-- | packages/web/src/lib/supabase/server.ts | 24 | ||||
| -rw-r--r-- | packages/web/src/server/api/routers/post.ts | 2 | ||||
| -rw-r--r-- | packages/web/src/server/api/trpc.ts | 13 | ||||
| -rw-r--r-- | packages/web/src/server/auth/config.ts | 66 | ||||
| -rw-r--r-- | packages/web/src/server/auth/index.ts | 21 | ||||
| -rw-r--r-- | packages/web/src/server/db/schema.ts | 80 |
12 files changed, 271 insertions, 169 deletions
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'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'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] })], -); |