feat: add invite code system and multi-image Drone pipeline
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:
admin
2026-03-21 15:33:12 +01:00
parent 30fb96b3f9
commit 031a4687fb
12 changed files with 403 additions and 18 deletions

View File

@@ -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 } };

View File

@@ -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"