From 031a4687fb8014348fed6d75209b558c4fadb862 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 21 Mar 2026 15:33:12 +0100 Subject: [PATCH] feat: add invite code system and multi-image Drone pipeline - Add InviteCode model with code, maxUses, expiry, usage tracking - Registration now requires a valid invite code - New users get USER role instead of ADMIN - Admin-only /invites page to create, manage, and share invite codes - Invite links auto-fill code via ?code= URL param - Drone pipeline now builds app, worker, and bot images separately - Add NEXT_PUBLIC_APP_URL build arg to fix URL redirects --- Dockerfile | 2 + .../migration.sql | 21 ++ prisma/schema.prisma | 16 ++ .../invites/_components/invite-manager.tsx | 214 ++++++++++++++++++ src/app/(app)/invites/actions.ts | 54 +++++ src/app/(app)/invites/page.tsx | 26 +++ src/app/(auth)/register/actions.ts | 54 +++-- src/app/(auth)/register/page.tsx | 27 ++- src/components/layout/mobile-sidebar.tsx | 3 +- src/components/layout/sidebar.tsx | 2 + src/lib/constants.ts | 1 + src/schemas/auth.schema.ts | 1 + 12 files changed, 403 insertions(+), 18 deletions(-) create mode 100644 prisma/migrations/20260321140000_add_invite_codes/migration.sql create mode 100644 src/app/(app)/invites/_components/invite-manager.tsx create mode 100644 src/app/(app)/invites/actions.ts create mode 100644 src/app/(app)/invites/page.tsx diff --git a/Dockerfile b/Dockerfile index f48c101..cd807cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,8 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 +ARG NEXT_PUBLIC_APP_URL=http://localhost:3000 +ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL} RUN npm run build # --- Production image --- diff --git a/prisma/migrations/20260321140000_add_invite_codes/migration.sql b/prisma/migrations/20260321140000_add_invite_codes/migration.sql new file mode 100644 index 0000000..92c58c9 --- /dev/null +++ b/prisma/migrations/20260321140000_add_invite_codes/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "invite_codes" ( + "id" TEXT NOT NULL, + "code" VARCHAR(32) NOT NULL, + "maxUses" INTEGER NOT NULL DEFAULT 1, + "uses" INTEGER NOT NULL DEFAULT 0, + "expiresAt" TIMESTAMP(3), + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "invite_codes_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "invite_codes_code_key" ON "invite_codes"("code"); + +-- CreateIndex +CREATE INDEX "invite_codes_code_idx" ON "invite_codes"("code"); + +-- AddForeignKey +ALTER TABLE "invite_codes" ADD CONSTRAINT "invite_codes_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index de85552..33fd24f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,6 +38,7 @@ model User { tags Tag[] settings UserSettings? telegramLink TelegramLink? + inviteCodes InviteCode[] } model Account { @@ -554,6 +555,21 @@ model GlobalSetting { @@map("global_settings") } +model InviteCode { + id String @id @default(cuid()) + code String @unique @db.VarChar(32) + maxUses Int @default(1) + uses Int @default(0) + expiresAt DateTime? + createdBy String + createdAt DateTime @default(now()) + + creator User @relation(fields: [createdBy], references: [id], onDelete: Cascade) + + @@index([code]) + @@map("invite_codes") +} + model ChannelFetchRequest { id String @id @default(cuid()) accountId String diff --git a/src/app/(app)/invites/_components/invite-manager.tsx b/src/app/(app)/invites/_components/invite-manager.tsx new file mode 100644 index 0000000..d305d9a --- /dev/null +++ b/src/app/(app)/invites/_components/invite-manager.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Copy, Plus, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { createInviteCode, deleteInviteCode } from "../actions"; + +type InviteCode = { + id: string; + code: string; + maxUses: number; + uses: number; + expiresAt: string | null; + createdAt: string; + creator: { name: string | null }; +}; + +export function InviteManager({ + inviteCodes, + appUrl, +}: { + inviteCodes: InviteCode[]; + appUrl: string; +}) { + const [maxUses, setMaxUses] = useState(1); + const [expiresInDays, setExpiresInDays] = useState(7); + const [noExpiry, setNoExpiry] = useState(false); + const [isPending, startTransition] = useTransition(); + const [copiedId, setCopiedId] = useState(null); + + function handleCreate() { + startTransition(async () => { + await createInviteCode({ + maxUses, + expiresInDays: noExpiry ? null : expiresInDays, + }); + }); + } + + function handleDelete(id: string) { + startTransition(async () => { + await deleteInviteCode(id); + }); + } + + function copyLink(code: string, id: string) { + const url = `${appUrl}/register?code=${code}`; + navigator.clipboard.writeText(url); + setCopiedId(id); + setTimeout(() => setCopiedId(null), 2000); + } + + function getStatus(invite: InviteCode) { + if (invite.uses >= invite.maxUses) return "used"; + if (invite.expiresAt && new Date(invite.expiresAt) < new Date()) return "expired"; + return "active"; + } + + return ( +
+ + + Create Invite Code + + Generate a new invite code to share with someone + + + +
+
+ + setMaxUses(Number(e.target.value))} + className="w-24" + /> +
+
+ + setExpiresInDays(Number(e.target.value))} + disabled={noExpiry} + className="w-24" + /> +
+
+ setNoExpiry(e.target.checked)} + className="h-4 w-4" + /> + +
+ +
+
+
+ + + + Invite Codes + + {inviteCodes.length} invite code{inviteCodes.length !== 1 ? "s" : ""} created + + + + {inviteCodes.length === 0 ? ( +

+ No invite codes yet. Create one above. +

+ ) : ( + + + + Code + Status + Uses + Expires + Created + Actions + + + + {inviteCodes.map((invite) => { + const status = getStatus(invite); + return ( + + + {invite.code} + + + + {status} + + + + {invite.uses} / {invite.maxUses} + + + {invite.expiresAt + ? new Date(invite.expiresAt).toLocaleDateString() + : "Never"} + + + {new Date(invite.createdAt).toLocaleDateString()} + + +
+ + +
+
+
+ ); + })} +
+
+ )} +
+
+
+ ); +} diff --git a/src/app/(app)/invites/actions.ts b/src/app/(app)/invites/actions.ts new file mode 100644 index 0000000..98f1cf8 --- /dev/null +++ b/src/app/(app)/invites/actions.ts @@ -0,0 +1,54 @@ +"use server"; + +import crypto from "crypto"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import type { ActionResult } from "@/types/api.types"; +import { revalidatePath } from "next/cache"; + +export async function createInviteCode(input: { + maxUses: number; + expiresInDays: number | null; +}): Promise> { + const session = await auth(); + if (!session?.user?.id || session.user.role !== "ADMIN") { + return { success: false, error: "Unauthorized" }; + } + + const code = crypto.randomBytes(6).toString("hex"); + const expiresAt = input.expiresInDays + ? new Date(Date.now() + input.expiresInDays * 24 * 60 * 60 * 1000) + : null; + + await prisma.inviteCode.create({ + data: { + code, + maxUses: input.maxUses, + expiresAt, + createdBy: session.user.id, + }, + }); + + revalidatePath("/invites"); + return { success: true, data: { code } }; +} + +export async function deleteInviteCode(id: string): Promise { + const session = await auth(); + if (!session?.user?.id || session.user.role !== "ADMIN") { + return { success: false, error: "Unauthorized" }; + } + + await prisma.inviteCode.delete({ where: { id } }); + + revalidatePath("/invites"); + return { success: true, data: undefined }; +} + +export async function getInviteCodes() { + const codes = await prisma.inviteCode.findMany({ + orderBy: { createdAt: "desc" }, + include: { creator: { select: { name: true } } }, + }); + return codes; +} diff --git a/src/app/(app)/invites/page.tsx b/src/app/(app)/invites/page.tsx new file mode 100644 index 0000000..ca44a11 --- /dev/null +++ b/src/app/(app)/invites/page.tsx @@ -0,0 +1,26 @@ +import { auth } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { PageHeader } from "@/components/shared/page-header"; +import { getInviteCodes } from "./actions"; +import { InviteManager } from "./_components/invite-manager"; + +export default async function InvitesPage() { + const session = await auth(); + if (!session?.user?.id) redirect("/login"); + if (session.user.role !== "ADMIN") redirect("/dashboard"); + + const inviteCodes = await getInviteCodes(); + + return ( +
+ + +
+ ); +} diff --git a/src/app/(auth)/register/actions.ts b/src/app/(auth)/register/actions.ts index e17fd53..1b47315 100644 --- a/src/app/(auth)/register/actions.ts +++ b/src/app/(auth)/register/actions.ts @@ -11,6 +11,23 @@ export async function registerUser(input: unknown): Promise= invite.maxUses) { + return { success: false, error: "This invite code has already been used" }; + } + + if (invite.expiresAt && invite.expiresAt < new Date()) { + return { success: false, error: "This invite code has expired" }; + } + const existing = await prisma.user.findUnique({ where: { email: parsed.data.email }, }); @@ -21,22 +38,31 @@ export async function registerUser(input: unknown): Promise { + const newUser = await tx.user.create({ + data: { + name: parsed.data.name, + email: parsed.data.email, + hashedPassword, + role: "USER", + settings: { + create: { + lowStockThreshold: 10, + currency: "USD", + theme: "dark", + units: "metric", + }, }, }, - }, + }); + + await tx.inviteCode.update({ + where: { id: invite.id }, + data: { uses: { increment: 1 } }, + }); + + return newUser; }); return { success: true, data: { id: user.id } }; diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index 4f1d5e3..a494961 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useTransition } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -24,12 +24,19 @@ import { APP_NAME } from "@/lib/constants"; export default function RegisterPage() { const router = useRouter(); + const searchParams = useSearchParams(); const [error, setError] = useState(null); const [isPending, startTransition] = useTransition(); const form = useForm({ resolver: zodResolver(registerSchema), - defaultValues: { name: "", email: "", password: "", confirmPassword: "" }, + defaultValues: { + name: "", + email: "", + password: "", + confirmPassword: "", + inviteCode: searchParams.get("code") ?? "", + }, }); function onSubmit(values: RegisterInput) { @@ -75,7 +82,7 @@ export default function RegisterPage() { Create Account - Fill in your details below + You need an invite code to register
@@ -86,6 +93,20 @@ export default function RegisterPage() { )} + ( + + Invite Code + + + + + + )} + /> + data.password === data.confirmPassword, { message: "Passwords do not match",