mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-11 06:11:15 +00:00
feat: add invite code system and multi-image Drone pipeline
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
- 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
This commit is contained in:
214
src/app/(app)/invites/_components/invite-manager.tsx
Normal file
214
src/app/(app)/invites/_components/invite-manager.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create Invite Code</CardTitle>
|
||||
<CardDescription>
|
||||
Generate a new invite code to share with someone
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxUses">Max Uses</Label>
|
||||
<Input
|
||||
id="maxUses"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(Number(e.target.value))}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expiresInDays">
|
||||
Expires in (days)
|
||||
</Label>
|
||||
<Input
|
||||
id="expiresInDays"
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={expiresInDays}
|
||||
onChange={(e) => setExpiresInDays(Number(e.target.value))}
|
||||
disabled={noExpiry}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="noExpiry"
|
||||
checked={noExpiry}
|
||||
onChange={(e) => setNoExpiry(e.target.checked)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="noExpiry" className="text-sm">No expiry</Label>
|
||||
</div>
|
||||
<Button onClick={handleCreate} disabled={isPending}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{isPending ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Invite Codes</CardTitle>
|
||||
<CardDescription>
|
||||
{inviteCodes.length} invite code{inviteCodes.length !== 1 ? "s" : ""} created
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{inviteCodes.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No invite codes yet. Create one above.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Uses</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{inviteCodes.map((invite) => {
|
||||
const status = getStatus(invite);
|
||||
return (
|
||||
<TableRow key={invite.id}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{invite.code}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
status === "active"
|
||||
? "default"
|
||||
: status === "used"
|
||||
? "secondary"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{invite.uses} / {invite.maxUses}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{invite.expiresAt
|
||||
? new Date(invite.expiresAt).toLocaleDateString()
|
||||
: "Never"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(invite.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyLink(invite.code, invite.id)}
|
||||
disabled={status !== "active"}
|
||||
>
|
||||
<Copy className="mr-1 h-3 w-3" />
|
||||
{copiedId === invite.id ? "Copied!" : "Copy Link"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(invite.id)}
|
||||
disabled={isPending}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/app/(app)/invites/actions.ts
Normal file
54
src/app/(app)/invites/actions.ts
Normal file
@@ -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<ActionResult<{ code: string }>> {
|
||||
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<ActionResult> {
|
||||
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;
|
||||
}
|
||||
26
src/app/(app)/invites/page.tsx
Normal file
26
src/app/(app)/invites/page.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Invite Codes"
|
||||
description="Manage invite codes for new user registration"
|
||||
/>
|
||||
<InviteManager
|
||||
inviteCodes={JSON.parse(JSON.stringify(inviteCodes))}
|
||||
appUrl={process.env.NEXT_PUBLIC_APP_URL ?? ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,23 @@ export async function registerUser(input: unknown): Promise<ActionResult<{ id: s
|
||||
return { success: false, error: "Validation failed" };
|
||||
}
|
||||
|
||||
// Validate invite code
|
||||
const invite = await prisma.inviteCode.findUnique({
|
||||
where: { code: parsed.data.inviteCode },
|
||||
});
|
||||
|
||||
if (!invite) {
|
||||
return { success: false, error: "Invalid invite code" };
|
||||
}
|
||||
|
||||
if (invite.uses >= 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<ActionResult<{ id: s
|
||||
|
||||
const hashedPassword = await bcrypt.hash(parsed.data.password, 10);
|
||||
|
||||
// Self-hosted: all users are admins
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: parsed.data.name,
|
||||
email: parsed.data.email,
|
||||
hashedPassword,
|
||||
role: "ADMIN",
|
||||
settings: {
|
||||
create: {
|
||||
lowStockThreshold: 10,
|
||||
currency: "USD",
|
||||
theme: "dark",
|
||||
units: "metric",
|
||||
// Create user and increment invite usage in a transaction
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
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 } };
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const form = useForm<RegisterInput>({
|
||||
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() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create Account</CardTitle>
|
||||
<CardDescription>Fill in your details below</CardDescription>
|
||||
<CardDescription>You need an invite code to register</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
@@ -86,6 +93,20 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="inviteCode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Invite Code</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter your invite code" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
|
||||
Reference in New Issue
Block a user