From 2be3eacefd6730ee915b92a7e0b416d99c5661cf Mon Sep 17 00:00:00 2001 From: ilia Date: Sun, 28 Dec 2025 23:14:28 -0500 Subject: [PATCH] Add auth, groups, and invite scaffolding --- eslint.config.mjs | 2 + package-lock.json | 80 +++------------- package.json | 2 +- src/app/api/auth/[...nextauth]/route.ts | 5 + src/app/g/[slug]/admin/invites/page.tsx | 118 ++++++++++++++++++++++++ src/app/g/[slug]/page.tsx | 66 +++++++++++++ src/app/groups/new/page.tsx | 60 ++++++++++++ src/app/groups/page.tsx | 63 +++++++++++++ src/app/invite/[token]/page.tsx | 79 ++++++++++++++++ src/app/login/page.tsx | 57 ++++++++++++ src/app/page.tsx | 85 +++++++---------- src/server/actions/groups.ts | 43 +++++++++ src/server/actions/invites.ts | 61 ++++++++++++ src/server/auth.ts | 45 +++++++++ src/server/db.ts | 13 +++ src/server/email.ts | 39 ++++++++ src/server/invites.ts | 10 ++ src/types/next-auth.d.ts | 14 +++ 18 files changed, 719 insertions(+), 123 deletions(-) create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/g/[slug]/admin/invites/page.tsx create mode 100644 src/app/g/[slug]/page.tsx create mode 100644 src/app/groups/new/page.tsx create mode 100644 src/app/groups/page.tsx create mode 100644 src/app/invite/[token]/page.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/server/actions/groups.ts create mode 100644 src/server/actions/invites.ts create mode 100644 src/server/auth.ts create mode 100644 src/server/db.ts create mode 100644 src/server/email.ts create mode 100644 src/server/invites.ts create mode 100644 src/types/next-auth.d.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..8e0f8e2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,8 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + // Local docker volumes (often root-owned, can break eslint file walking): + "docker/**", ]), ]); diff --git a/package-lock.json b/package-lock.json index 360801c..bf60258 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,9 @@ "name": "mirrormatch", "version": "0.1.0", "dependencies": { - "@auth/prisma-adapter": "^2.11.1", "@aws-sdk/client-s3": "^3.958.0", "@aws-sdk/s3-request-presigner": "^3.958.0", + "@next-auth/prisma-adapter": "^1.0.7", "@prisma/client": "^7.2.0", "next": "16.1.1", "next-auth": "^4.24.13", @@ -125,74 +125,6 @@ "preact": ">=10" } }, - "node_modules/@auth/prisma-adapter": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.1.tgz", - "integrity": "sha512-Ke7DXP0Fy0Mlmjz/ZJLXwQash2UkA4621xCM0rMtEczr1kppLc/njCbUkHkIQ/PnmILjqSPEKeTjDPsYruvkug==", - "license": "ISC", - "dependencies": { - "@auth/core": "0.41.1" - }, - "peerDependencies": { - "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" - } - }, - "node_modules/@auth/prisma-adapter/node_modules/@auth/core": { - "version": "0.41.1", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz", - "integrity": "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw==", - "license": "ISC", - "dependencies": { - "@panva/hkdf": "^1.2.1", - "jose": "^6.0.6", - "oauth4webapi": "^3.3.0", - "preact": "10.24.3", - "preact-render-to-string": "6.5.11" - }, - "peerDependencies": { - "@simplewebauthn/browser": "^9.0.1", - "@simplewebauthn/server": "^9.0.2", - "nodemailer": "^7.0.7" - }, - "peerDependenciesMeta": { - "@simplewebauthn/browser": { - "optional": true - }, - "@simplewebauthn/server": { - "optional": true - }, - "nodemailer": { - "optional": true - } - } - }, - "node_modules/@auth/prisma-adapter/node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/@auth/prisma-adapter/node_modules/oauth4webapi": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.3.tgz", - "integrity": "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/@auth/prisma-adapter/node_modules/preact-render-to-string": { - "version": "6.5.11", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", - "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", - "license": "MIT", - "peerDependencies": { - "preact": ">=10" - } - }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -2217,6 +2149,16 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@next-auth/prisma-adapter": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@next-auth/prisma-adapter/-/prisma-adapter-1.0.7.tgz", + "integrity": "sha512-Cdko4KfcmKjsyHFrWwZ//lfLUbcLqlyFqjd/nYE2m3aZ7tjMNUjpks47iw7NTCnXf+5UWz5Ypyt1dSs1EP5QJw==", + "license": "ISC", + "peerDependencies": { + "@prisma/client": ">=2.26.0 || >=3", + "next-auth": "^4" + } + }, "node_modules/@next/env": { "version": "16.1.1", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz", diff --git a/package.json b/package.json index 3df1a58..02cb03c 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,9 @@ "lint": "eslint" }, "dependencies": { - "@auth/prisma-adapter": "^2.11.1", "@aws-sdk/client-s3": "^3.958.0", "@aws-sdk/s3-request-presigner": "^3.958.0", + "@next-auth/prisma-adapter": "^1.0.7", "@prisma/client": "^7.2.0", "next": "16.1.1", "next-auth": "^4.24.13", diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..cf71118 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,5 @@ +import { nextAuthHandler } from "@/server/auth"; + +export { nextAuthHandler as GET, nextAuthHandler as POST }; + + diff --git a/src/app/g/[slug]/admin/invites/page.tsx b/src/app/g/[slug]/admin/invites/page.tsx new file mode 100644 index 0000000..da3e79d --- /dev/null +++ b/src/app/g/[slug]/admin/invites/page.tsx @@ -0,0 +1,118 @@ +import { redirect } from "next/navigation"; + +import { auth } from "@/server/auth"; +import { prisma } from "@/server/db"; +import { createInvite } from "@/server/actions/invites"; + +export default async function GroupInvitesAdminPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + + const { slug } = await params; + + const membership = await prisma.groupMember.findFirst({ + where: { userId: session.user.id, group: { slug } }, + include: { group: true }, + }); + + if (!membership || membership.role !== "ADMIN") { + return ( +
+
+

Admins only

+
+
+ ); + } + + const invites = await prisma.invite.findMany({ + where: { groupId: membership.groupId }, + orderBy: { createdAt: "desc" }, + take: 50, + }); + + return ( +
+
+

Invites

+

{membership.group.name}

+ +
{ + "use server"; + await createInvite(formData); + }} + className="mt-8 grid gap-3 rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm sm:grid-cols-4" + > + + +
+ + +
+ +
+ + +
+ +
+ +
+
+ +
+
+ Recent invites +
+
    + {invites.length === 0 ? ( +
  • No invites yet.
  • + ) : ( + invites.map((i) => ( +
  • +
    +
    +
    {i.email}
    +
    + {i.role.toLowerCase()} •{" "} + {i.usedAt ? "used" : i.expiresAt < new Date() ? "expired" : "pending"} +
    +
    +
    + {i.createdAt.toISOString().slice(0, 10)} +
    +
    +
  • + )) + )} +
+
+
+
+ ); +} + + diff --git a/src/app/g/[slug]/page.tsx b/src/app/g/[slug]/page.tsx new file mode 100644 index 0000000..7f890cb --- /dev/null +++ b/src/app/g/[slug]/page.tsx @@ -0,0 +1,66 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { auth } from "@/server/auth"; +import { prisma } from "@/server/db"; + +export default async function GroupPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + + const { slug } = await params; + + const membership = await prisma.groupMember.findFirst({ + where: { userId: session.user.id, group: { slug } }, + include: { group: true }, + }); + + if (!membership) { + return ( +
+
+

Not a member

+

+ You need an invite link to join this group. +

+ + Back to groups + +
+
+ ); + } + + const isAdmin = membership.role === "ADMIN"; + + return ( +
+
+
+
+

{membership.group.name}

+

/g/{membership.group.slug}

+
+ {isAdmin ? ( + + Admin + + ) : null} +
+ +
+ Next: sets + upload + guessing UI. +
+
+
+ ); +} + + diff --git a/src/app/groups/new/page.tsx b/src/app/groups/new/page.tsx new file mode 100644 index 0000000..118ad33 --- /dev/null +++ b/src/app/groups/new/page.tsx @@ -0,0 +1,60 @@ +import { auth } from "@/server/auth"; +import { redirect } from "next/navigation"; +import { createGroup } from "@/server/actions/groups"; + +export default async function NewGroupPage() { + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + + return ( +
+
+

Create a group

+

+ Example: “Family”, “Brother’s friends”, “Cousins”. +

+ +
{ + "use server"; + await createGroup(formData); + }} + className="mt-8 space-y-4 rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm" + > +
+ + +
+ +
+ + +

+ Lowercase letters, numbers, and dashes only. +

+
+ + +
+
+
+ ); +} + + diff --git a/src/app/groups/page.tsx b/src/app/groups/page.tsx new file mode 100644 index 0000000..ac4daca --- /dev/null +++ b/src/app/groups/page.tsx @@ -0,0 +1,63 @@ +import Link from "next/link"; +import { auth } from "@/server/auth"; +import { prisma } from "@/server/db"; +import { redirect } from "next/navigation"; + +export default async function GroupsPage() { + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + + const memberships = await prisma.groupMember.findMany({ + where: { userId: session.user.id }, + include: { group: true }, + orderBy: { joinedAt: "desc" }, + }); + + return ( +
+
+
+
+

Your groups

+

+ Invite-only spaces for sets, guesses, and leaderboards. +

+
+ + Create group + +
+ +
+ {memberships.length === 0 ? ( +
+ No groups yet. Create one, then invite family/friends. +
+ ) : ( + memberships.map((m) => ( + +
+
+
{m.group.name}
+
+ /g/{m.group.slug} • {m.role.toLowerCase()} +
+
+
+ + )) + )} +
+
+
+ ); +} + + diff --git a/src/app/invite/[token]/page.tsx b/src/app/invite/[token]/page.tsx new file mode 100644 index 0000000..501c6fa --- /dev/null +++ b/src/app/invite/[token]/page.tsx @@ -0,0 +1,79 @@ +import { auth } from "@/server/auth"; +import { prisma } from "@/server/db"; +import crypto from "crypto"; +import { redirect } from "next/navigation"; + +function hashToken(token: string) { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +export default async function InviteRedeemPage({ + params, +}: { + params: Promise<{ token: string }>; +}) { + const session = await auth(); + if (!session?.user?.id) { + // After login, come back and redeem. + const { token } = await params; + redirect(`/login?callbackUrl=${encodeURIComponent(`/invite/${token}`)}`); + } + + const { token } = await params; + const tokenHash = hashToken(token); + + const invite = await prisma.invite.findUnique({ + where: { tokenHash }, + }); + + if (!invite) { + return ( +
+
+

Invite not found

+

+ This invite link is invalid or was already used. +

+
+
+ ); + } + + const now = new Date(); + if (invite.usedAt || invite.expiresAt <= now) { + return ( +
+
+

Invite expired

+

+ Ask an admin to send you a new invite. +

+
+
+ ); + } + + // Redeem: create membership and mark invite used. If already a member, just mark used. + const userId = session.user.id; + + await prisma.$transaction(async (tx) => { + await tx.groupMember.upsert({ + where: { groupId_userId: { groupId: invite.groupId, userId } }, + update: {}, + create: { groupId: invite.groupId, userId, role: invite.role }, + }); + + await tx.invite.update({ + where: { id: invite.id }, + data: { + usedAt: now, + usedById: userId, + }, + }); + }); + + const group = await prisma.group.findUnique({ where: { id: invite.groupId } }); + redirect(group ? `/g/${group.slug}` : "/groups"); +} + + diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..5ffb73b --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,57 @@ +import { auth } from "@/server/auth"; +import { redirect } from "next/navigation"; + +export default async function LoginPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const session = await auth(); + if (session?.user) redirect("/groups"); + + const params = await searchParams; + const check = params.check === "1"; + + return ( +
+
+

MirrorMatch

+

+ Invite-only. Sign in to view your groups and play. +

+ +
+ {check ? ( +
+ Check your email for a magic link. +
+ ) : null} + +
+ + + +
+ +

+ You’ll need an invite to join a group. +

+
+
+
+ ); +} + + diff --git a/src/app/page.tsx b/src/app/page.tsx index 295f8fd..ba09126 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,44 @@ -import Image from "next/image"; +import Link from "next/link"; +import { auth } from "@/server/auth"; -export default function Home() { +export default async function Home() { + const session = await auth(); return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - +

+
); } diff --git a/src/server/actions/groups.ts b/src/server/actions/groups.ts new file mode 100644 index 0000000..ab118b7 --- /dev/null +++ b/src/server/actions/groups.ts @@ -0,0 +1,43 @@ +"use server"; + +import { z } from "zod"; +import { redirect } from "next/navigation"; + +import { auth } from "@/server/auth"; +import { prisma } from "@/server/db"; + +const createGroupSchema = z.object({ + name: z.string().trim().min(1).max(80), + slug: z + .string() + .trim() + .toLowerCase() + .regex(/^[a-z0-9-]+$/) + .min(2) + .max(40), +}); + +export async function createGroup(formData: FormData) { + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + + const data = createGroupSchema.parse({ + name: formData.get("name"), + slug: formData.get("slug"), + }); + + const group = await prisma.group.create({ + data: { + name: data.name, + slug: data.slug, + createdById: session.user.id, + members: { + create: { userId: session.user.id, role: "ADMIN" }, + }, + }, + }); + + redirect(`/g/${group.slug}`); +} + + diff --git a/src/server/actions/invites.ts b/src/server/actions/invites.ts new file mode 100644 index 0000000..7efe403 --- /dev/null +++ b/src/server/actions/invites.ts @@ -0,0 +1,61 @@ +"use server"; + +import { z } from "zod"; +import { redirect } from "next/navigation"; + +import { auth } from "@/server/auth"; +import { prisma } from "@/server/db"; +import { newInviteToken } from "@/server/invites"; +import { sendInviteEmail } from "@/server/email"; + +const createInviteSchema = z.object({ + groupSlug: z.string().min(1), + email: z.string().trim().toLowerCase().email(), + role: z.enum(["ADMIN", "MEMBER"]).default("MEMBER"), +}); + +export async function createInvite(formData: FormData) { + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + + const data = createInviteSchema.parse({ + groupSlug: formData.get("groupSlug"), + email: formData.get("email"), + role: formData.get("role") ?? "MEMBER", + }); + + const membership = await prisma.groupMember.findFirst({ + where: { userId: session.user.id, group: { slug: data.groupSlug } }, + include: { group: true }, + }); + if (!membership || membership.role !== "ADMIN") { + throw new Error("Not authorized"); + } + + const { token, tokenHash } = newInviteToken(); + const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7); // 7 days + + const invite = await prisma.invite.create({ + data: { + groupId: membership.groupId, + email: data.email, + role: data.role, + tokenHash, + createdById: session.user.id, + expiresAt, + }, + }); + + const baseUrl = process.env.AUTH_URL ?? "http://localhost:3000"; + const inviteUrl = `${baseUrl}/invite/${token}`; + + await sendInviteEmail({ + to: data.email, + groupName: membership.group.name, + inviteUrl, + }); + + return { inviteId: invite.id, inviteUrl }; +} + + diff --git a/src/server/auth.ts b/src/server/auth.ts new file mode 100644 index 0000000..f12548e --- /dev/null +++ b/src/server/auth.ts @@ -0,0 +1,45 @@ +import type { NextAuthOptions } from "next-auth"; +import NextAuth from "next-auth"; +import { getServerSession } from "next-auth"; +import EmailProvider from "next-auth/providers/email"; +import { PrismaAdapter } from "@next-auth/prisma-adapter"; + +import { prisma } from "@/server/db"; + +export const authOptions: NextAuthOptions = { + adapter: PrismaAdapter(prisma), + secret: process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET, + providers: [ + EmailProvider({ + from: process.env.EMAIL_FROM, + server: { + host: process.env.EMAIL_SERVER_HOST, + port: Number(process.env.EMAIL_SERVER_PORT ?? 587), + auth: process.env.EMAIL_SERVER_USER + ? { + user: process.env.EMAIL_SERVER_USER, + pass: process.env.EMAIL_SERVER_PASSWORD, + } + : undefined, + }, + }), + ], + pages: { + signIn: "/login", + verifyRequest: "/login?check=1", + }, + callbacks: { + async session({ session, user }) { + if (session.user) session.user.id = user.id; + return session; + }, + }, +}; + +export const nextAuthHandler = NextAuth(authOptions); + +export function auth() { + return getServerSession(authOptions); +} + + diff --git a/src/server/db.ts b/src/server/db.ts new file mode 100644 index 0000000..f5b0203 --- /dev/null +++ b/src/server/db.ts @@ -0,0 +1,13 @@ +import { PrismaClient } from "@/generated/prisma"; + +const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; + +export const prisma = + globalForPrisma.prisma ?? + new PrismaClient({ + log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"], + }); + +if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; + + diff --git a/src/server/email.ts b/src/server/email.ts new file mode 100644 index 0000000..41b415c --- /dev/null +++ b/src/server/email.ts @@ -0,0 +1,39 @@ +import nodemailer from "nodemailer"; + +export function getSmtpTransport() { + const host = process.env.EMAIL_SERVER_HOST; + const port = Number(process.env.EMAIL_SERVER_PORT ?? 587); + const user = process.env.EMAIL_SERVER_USER; + const pass = process.env.EMAIL_SERVER_PASSWORD; + + if (!host) throw new Error("Missing EMAIL_SERVER_HOST"); + + return nodemailer.createTransport({ + host, + port, + auth: user ? { user, pass } : undefined, + }); +} + +export async function sendInviteEmail(args: { + to: string; + groupName: string; + inviteUrl: string; +}) { + const from = process.env.EMAIL_FROM; + if (!from) throw new Error("Missing EMAIL_FROM"); + + const transport = getSmtpTransport(); + + const subject = `MirrorMatch invite: ${args.groupName}`; + const text = `You were invited to join "${args.groupName}" on MirrorMatch.\n\nJoin: ${args.inviteUrl}\n\nIf you didn't expect this, you can ignore this email.\n`; + + await transport.sendMail({ + from, + to: args.to, + subject, + text, + }); +} + + diff --git a/src/server/invites.ts b/src/server/invites.ts new file mode 100644 index 0000000..a554bb8 --- /dev/null +++ b/src/server/invites.ts @@ -0,0 +1,10 @@ +import crypto from "crypto"; + +export function newInviteToken() { + // URL-safe token + const token = crypto.randomBytes(32).toString("base64url"); + const tokenHash = crypto.createHash("sha256").update(token).digest("hex"); + return { token, tokenHash }; +} + + diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..dd016e5 --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,14 @@ +import "next-auth"; + +declare module "next-auth" { + interface Session { + user?: { + id: string; + name?: string | null; + email?: string | null; + image?: string | null; + }; + } +} + +