2 Commits

Author SHA1 Message Date
admin
031a4687fb 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
2026-03-21 15:41:12 +01:00
admin
30fb96b3f9 fix: replace drone-ssh with alpine SSH and fix YAML indentation 2026-03-21 15:41:12 +01:00
13 changed files with 465 additions and 49 deletions

View File

@@ -1,35 +1,66 @@
---
kind: pipeline
type: docker
name: build-and-deploy
kind: pipeline
type: docker
name: build-and-deploy
trigger:
branch: [main]
event: [push]
trigger:
branch: [main]
event: [push]
steps:
- name: build
image: plugins/docker
settings:
repo: git.samagsteribbe.nl/admin/dragonsstash
registry: git.samagsteribbe.nl
tags:
- latest
- "${DRONE_COMMIT_SHA:0:8}"
username:
from_secret: gitea_username
password:
from_secret: gitea_password
steps:
- name: build-app
image: plugins/docker
settings:
repo: git.samagsteribbe.nl/admin/dragonsstash
registry: git.samagsteribbe.nl
dockerfile: Dockerfile
tags:
- latest
- "${DRONE_COMMIT_SHA:0:8}"
build_args:
- NEXT_PUBLIC_APP_URL=https://dragonsstash.samagsteribbe.nl
username:
from_secret: gitea_username
password:
from_secret: gitea_password
- name: deploy
image: alpine
environment:
SSH_KEY:
from_secret: ssh_key
commands:
- apk add --no-cache openssh-client
- mkdir -p ~/.ssh
- printf "%s" "$SSH_KEY" > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- ssh-keyscan -t ed25519 192.168.68.68 > ~/.ssh/known_hosts 2>/dev/null
- ssh sam@192.168.68.68 "cd /opt/stacks/DragonsStash && docker compose pull && docker compose up -d"
- name: build-worker
image: plugins/docker
settings:
repo: git.samagsteribbe.nl/admin/dragonsstash-worker
registry: git.samagsteribbe.nl
dockerfile: worker/Dockerfile
tags:
- latest
- "${DRONE_COMMIT_SHA:0:8}"
username:
from_secret: gitea_username
password:
from_secret: gitea_password
- name: build-bot
image: plugins/docker
settings:
repo: git.samagsteribbe.nl/admin/dragonsstash-bot
registry: git.samagsteribbe.nl
dockerfile: bot/Dockerfile
tags:
- latest
- "${DRONE_COMMIT_SHA:0:8}"
username:
from_secret: gitea_username
password:
from_secret: gitea_password
- name: deploy
image: alpine
environment:
SSH_KEY:
from_secret: ssh_key
commands:
- apk add --no-cache openssh-client
- mkdir -p ~/.ssh
- printf "%s" "$SSH_KEY" > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- ssh-keyscan -t ed25519 192.168.68.68 > ~/.ssh/known_hosts 2>/dev/null
- ssh sam@192.168.68.68 "cd /opt/stacks/DragonsStash && docker compose pull && docker compose up -d"

View File

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

View File

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

View File

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

View 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>
);
}

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

View 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>
);
}

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"

View File

@@ -15,13 +15,14 @@ import {
Building2,
MapPin,
Settings,
UserPlus,
Flame,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { APP_NAME, NAV_ITEMS } from "@/lib/constants";
import { SheetHeader, SheetTitle } from "@/components/ui/sheet";
const icons = { LayoutDashboard, Cylinder, Droplets, Paintbrush, Gem, FileBox, Send, ClipboardList, Building2, MapPin, Settings };
const icons = { LayoutDashboard, Cylinder, Droplets, Paintbrush, Gem, FileBox, Send, ClipboardList, Building2, MapPin, Settings, UserPlus };
export function MobileSidebar() {
const pathname = usePathname();

View File

@@ -16,6 +16,7 @@ import {
Building2,
MapPin,
Settings,
UserPlus,
Flame,
PanelLeftClose,
PanelLeft,
@@ -37,6 +38,7 @@ const icons = {
Building2,
MapPin,
Settings,
UserPlus,
} as const;
export function Sidebar() {

View File

@@ -8,6 +8,7 @@ export const NAV_ITEMS = [
{ label: "Supplies", href: "/supplies", icon: "Gem", adminOnly: false },
{ label: "STL Files", href: "/stls", icon: "FileBox", adminOnly: false },
{ label: "Telegram", href: "/telegram", icon: "Send", adminOnly: true },
{ label: "Invites", href: "/invites", icon: "UserPlus", adminOnly: true },
{ label: "Usage", href: "/usage", icon: "ClipboardList", adminOnly: false },
{ label: "Vendors", href: "/vendors", icon: "Building2", adminOnly: false },
{ label: "Locations", href: "/locations", icon: "MapPin", adminOnly: false },

View File

@@ -11,6 +11,7 @@ export const registerSchema = z
email: z.email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
confirmPassword: z.string(),
inviteCode: z.string().min(1, "Invite code is required"),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",