mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-05-10 22:01:16 +00:00
Compare commits
2 Commits
9a077a3648
...
031a4687fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
031a4687fb | ||
|
|
30fb96b3f9 |
93
.drone.yml
93
.drone.yml
@@ -1,35 +1,66 @@
|
|||||||
---
|
---
|
||||||
kind: pipeline
|
kind: pipeline
|
||||||
type: docker
|
type: docker
|
||||||
name: build-and-deploy
|
name: build-and-deploy
|
||||||
|
|
||||||
trigger:
|
trigger:
|
||||||
branch: [main]
|
branch: [main]
|
||||||
event: [push]
|
event: [push]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: build
|
- name: build-app
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
repo: git.samagsteribbe.nl/admin/dragonsstash
|
repo: git.samagsteribbe.nl/admin/dragonsstash
|
||||||
registry: git.samagsteribbe.nl
|
registry: git.samagsteribbe.nl
|
||||||
tags:
|
dockerfile: Dockerfile
|
||||||
- latest
|
tags:
|
||||||
- "${DRONE_COMMIT_SHA:0:8}"
|
- latest
|
||||||
username:
|
- "${DRONE_COMMIT_SHA:0:8}"
|
||||||
from_secret: gitea_username
|
build_args:
|
||||||
password:
|
- NEXT_PUBLIC_APP_URL=https://dragonsstash.samagsteribbe.nl
|
||||||
from_secret: gitea_password
|
username:
|
||||||
|
from_secret: gitea_username
|
||||||
|
password:
|
||||||
|
from_secret: gitea_password
|
||||||
|
|
||||||
- name: deploy
|
- name: build-worker
|
||||||
image: alpine
|
image: plugins/docker
|
||||||
environment:
|
settings:
|
||||||
SSH_KEY:
|
repo: git.samagsteribbe.nl/admin/dragonsstash-worker
|
||||||
from_secret: ssh_key
|
registry: git.samagsteribbe.nl
|
||||||
commands:
|
dockerfile: worker/Dockerfile
|
||||||
- apk add --no-cache openssh-client
|
tags:
|
||||||
- mkdir -p ~/.ssh
|
- latest
|
||||||
- printf "%s" "$SSH_KEY" > ~/.ssh/id_ed25519
|
- "${DRONE_COMMIT_SHA:0:8}"
|
||||||
- chmod 600 ~/.ssh/id_ed25519
|
username:
|
||||||
- ssh-keyscan -t ed25519 192.168.68.68 > ~/.ssh/known_hosts 2>/dev/null
|
from_secret: gitea_username
|
||||||
- ssh sam@192.168.68.68 "cd /opt/stacks/DragonsStash && docker compose pull && docker compose up -d"
|
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"
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
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
|
RUN npm run build
|
||||||
|
|
||||||
# --- Production image ---
|
# --- Production image ---
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -38,6 +38,7 @@ model User {
|
|||||||
tags Tag[]
|
tags Tag[]
|
||||||
settings UserSettings?
|
settings UserSettings?
|
||||||
telegramLink TelegramLink?
|
telegramLink TelegramLink?
|
||||||
|
inviteCodes InviteCode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
@@ -554,6 +555,21 @@ model GlobalSetting {
|
|||||||
@@map("global_settings")
|
@@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 {
|
model ChannelFetchRequest {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
accountId String
|
accountId String
|
||||||
|
|||||||
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" };
|
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({
|
const existing = await prisma.user.findUnique({
|
||||||
where: { email: parsed.data.email },
|
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);
|
const hashedPassword = await bcrypt.hash(parsed.data.password, 10);
|
||||||
|
|
||||||
// Self-hosted: all users are admins
|
// Create user and increment invite usage in a transaction
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.$transaction(async (tx) => {
|
||||||
data: {
|
const newUser = await tx.user.create({
|
||||||
name: parsed.data.name,
|
data: {
|
||||||
email: parsed.data.email,
|
name: parsed.data.name,
|
||||||
hashedPassword,
|
email: parsed.data.email,
|
||||||
role: "ADMIN",
|
hashedPassword,
|
||||||
settings: {
|
role: "USER",
|
||||||
create: {
|
settings: {
|
||||||
lowStockThreshold: 10,
|
create: {
|
||||||
currency: "USD",
|
lowStockThreshold: 10,
|
||||||
theme: "dark",
|
currency: "USD",
|
||||||
units: "metric",
|
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 } };
|
return { success: true, data: { id: user.id } };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -24,12 +24,19 @@ import { APP_NAME } from "@/lib/constants";
|
|||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const form = useForm<RegisterInput>({
|
const form = useForm<RegisterInput>({
|
||||||
resolver: zodResolver(registerSchema),
|
resolver: zodResolver(registerSchema),
|
||||||
defaultValues: { name: "", email: "", password: "", confirmPassword: "" },
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
inviteCode: searchParams.get("code") ?? "",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function onSubmit(values: RegisterInput) {
|
function onSubmit(values: RegisterInput) {
|
||||||
@@ -75,7 +82,7 @@ export default function RegisterPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Create Account</CardTitle>
|
<CardTitle>Create Account</CardTitle>
|
||||||
<CardDescription>Fill in your details below</CardDescription>
|
<CardDescription>You need an invite code to register</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -86,6 +93,20 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
MapPin,
|
MapPin,
|
||||||
Settings,
|
Settings,
|
||||||
|
UserPlus,
|
||||||
Flame,
|
Flame,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { APP_NAME, NAV_ITEMS } from "@/lib/constants";
|
import { APP_NAME, NAV_ITEMS } from "@/lib/constants";
|
||||||
import { SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
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() {
|
export function MobileSidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
MapPin,
|
MapPin,
|
||||||
Settings,
|
Settings,
|
||||||
|
UserPlus,
|
||||||
Flame,
|
Flame,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
@@ -37,6 +38,7 @@ const icons = {
|
|||||||
Building2,
|
Building2,
|
||||||
MapPin,
|
MapPin,
|
||||||
Settings,
|
Settings,
|
||||||
|
UserPlus,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const NAV_ITEMS = [
|
|||||||
{ label: "Supplies", href: "/supplies", icon: "Gem", adminOnly: false },
|
{ label: "Supplies", href: "/supplies", icon: "Gem", adminOnly: false },
|
||||||
{ label: "STL Files", href: "/stls", icon: "FileBox", adminOnly: false },
|
{ label: "STL Files", href: "/stls", icon: "FileBox", adminOnly: false },
|
||||||
{ label: "Telegram", href: "/telegram", icon: "Send", adminOnly: true },
|
{ 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: "Usage", href: "/usage", icon: "ClipboardList", adminOnly: false },
|
||||||
{ label: "Vendors", href: "/vendors", icon: "Building2", adminOnly: false },
|
{ label: "Vendors", href: "/vendors", icon: "Building2", adminOnly: false },
|
||||||
{ label: "Locations", href: "/locations", icon: "MapPin", adminOnly: false },
|
{ label: "Locations", href: "/locations", icon: "MapPin", adminOnly: false },
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const registerSchema = z
|
|||||||
email: z.email("Invalid email address"),
|
email: z.email("Invalid email address"),
|
||||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||||
confirmPassword: z.string(),
|
confirmPassword: z.string(),
|
||||||
|
inviteCode: z.string().min(1, "Invite code is required"),
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.confirmPassword, {
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
message: "Passwords do not match",
|
message: "Passwords do not match",
|
||||||
|
|||||||
Reference in New Issue
Block a user