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 (
+
+ );
+ }
+
+ const invites = await prisma.invite.findMany({
+ where: { groupId: membership.groupId },
+ orderBy: { createdAt: "desc" },
+ take: 50,
+ });
+
+ return (
+
+
+
Invites
+
{membership.group.name}
+
+
+
+
+
+ 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”.
+
+
+
+
+
+ );
+}
+
+
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 (
-
-
-
-
);
}
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;
+ };
+ }
+}
+
+