feat(invite): implement invite token generation and registration flow

- Added DTOs for invite token creation, validation, and user registration.
- Implemented routes for generating invite tokens, validating tokens, and registering users with invite tokens.
- Created InviteService to handle business logic for invite token management.
- Integrated invite functionality into the server and web applications.
- Added UI components for generating invite links and registering with invites.
- Updated translations for invite-related messages in English and Portuguese.
- Introduced API endpoints for invite token operations.
- Enhanced user management UI to include invite link generation.
This commit is contained in:
Daniel Luiz Alves 2025-10-29 16:30:17 -03:00
parent 3dbd5b81ae
commit b58121b337
23 changed files with 1052 additions and 34 deletions

View File

@ -316,3 +316,15 @@ model Folder {
@@index([parentId])
@@map("folders")
}
model InviteToken {
id String @id @default(cuid())
token String @unique
expiresAt DateTime
usedAt DateTime?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("invite_tokens")
}

View File

@ -0,0 +1,87 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { InviteService } from "./service";
export class InviteController {
private inviteService = new InviteService();
async generateInviteToken(request: FastifyRequest, reply: FastifyReply) {
try {
const user = request.user as any;
if (!user || !user.isAdmin) {
return reply.status(403).send({ error: "Forbidden: admin access required" });
}
const { token, expiresAt } = await this.inviteService.generateInviteToken(user.userId || user.id);
return reply.send({ token, expiresAt });
} catch (error) {
console.error("[Invite Controller] Error generating invite token:", error);
return reply.status(500).send({ error: "Failed to generate invite token" });
}
}
async validateInviteToken(request: FastifyRequest<{ Params: { token: string } }>, reply: FastifyReply) {
try {
const { token } = request.params;
const validation = await this.inviteService.validateInviteToken(token);
return reply.send(validation);
} catch (error) {
console.error("Error validating invite token:", error);
return reply.status(500).send({ error: "Failed to validate invite token" });
}
}
async registerWithInvite(
request: FastifyRequest<{
Body: {
token: string;
firstName: string;
lastName: string;
username: string;
email: string;
password: string;
};
}>,
reply: FastifyReply
) {
try {
const { token, firstName, lastName, username, email, password } = request.body;
const user = await this.inviteService.registerWithInvite({
token,
firstName,
lastName,
username,
email,
password,
});
return reply.send({
message: "User registered successfully",
user,
});
} catch (error: any) {
console.error("Error registering with invite:", error);
if (error.message.includes("already been used")) {
return reply.status(400).send({ error: "This invite link has already been used" });
}
if (error.message.includes("expired")) {
return reply.status(400).send({ error: "This invite link has expired" });
}
if (error.message.includes("Invalid invite")) {
return reply.status(400).send({ error: "Invalid invite link" });
}
if (error.message.includes("Username already exists")) {
return reply.status(400).send({ error: "Username already exists" });
}
if (error.message.includes("Email already exists")) {
return reply.status(400).send({ error: "Email already exists" });
}
return reply.status(500).send({ error: "Failed to register user" });
}
}
}

View File

@ -0,0 +1,30 @@
import { z } from "zod";
export const CreateInviteTokenResponseSchema = z.object({
token: z.string().describe("Invite token"),
expiresAt: z.coerce.date().describe("Token expiration date"),
});
export const ValidateInviteTokenResponseSchema = z.object({
valid: z.boolean().describe("Whether the token is valid"),
used: z.boolean().optional().describe("Whether the token has been used"),
expired: z.boolean().optional().describe("Whether the token has expired"),
});
export const RegisterWithInviteSchema = z.object({
token: z.string().min(1, "Token is required").describe("Invite token"),
firstName: z.string().min(1, "First name is required").describe("User first name"),
lastName: z.string().min(1, "Last name is required").describe("User last name"),
username: z.string().min(3, "Username must be at least 3 characters").describe("User username"),
email: z.string().email("Invalid email").describe("User email"),
password: z.string().min(8, "Password must be at least 8 characters").describe("User password"),
});
export const RegisterWithInviteResponseSchema = z.object({
message: z.string().describe("Success message"),
user: z.object({
id: z.string().describe("User ID"),
username: z.string().describe("User username"),
email: z.string().email().describe("User email"),
}),
});

View File

@ -0,0 +1,79 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { InviteController } from "./controller";
import {
CreateInviteTokenResponseSchema,
RegisterWithInviteResponseSchema,
RegisterWithInviteSchema,
ValidateInviteTokenResponseSchema,
} from "./dto";
export async function inviteRoutes(app: FastifyInstance) {
const inviteController = new InviteController();
app.post(
"/invite-tokens",
{
schema: {
tags: ["Invite"],
operationId: "generateInviteToken",
summary: "Generate Invite Token",
description: "Generate a one-time use invite token for user registration (admin only)",
response: {
200: CreateInviteTokenResponseSchema,
403: z.object({ error: z.string().describe("Error message") }),
500: z.object({ error: z.string().describe("Error message") }),
},
},
preValidation: async (request: FastifyRequest, reply: FastifyReply) => {
try {
await request.jwtVerify();
} catch (err) {
console.error(err);
reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
},
},
inviteController.generateInviteToken.bind(inviteController)
);
app.get(
"/invite-tokens/:token",
{
schema: {
tags: ["Invite"],
operationId: "validateInviteToken",
summary: "Validate Invite Token",
description: "Check if an invite token is valid and can be used",
params: z.object({
token: z.string().describe("Invite token"),
}),
response: {
200: ValidateInviteTokenResponseSchema,
500: z.object({ error: z.string().describe("Error message") }),
},
},
},
inviteController.validateInviteToken.bind(inviteController)
);
app.post(
"/register-with-invite",
{
schema: {
tags: ["Invite"],
operationId: "registerWithInvite",
summary: "Register with Invite",
description: "Create a new user account using an invite token",
body: RegisterWithInviteSchema,
response: {
200: RegisterWithInviteResponseSchema,
400: z.object({ error: z.string().describe("Error message") }),
500: z.object({ error: z.string().describe("Error message") }),
},
},
},
inviteController.registerWithInvite.bind(inviteController)
);
}

View File

@ -0,0 +1,107 @@
import { randomBytes } from "crypto";
import bcrypt from "bcryptjs";
import { prisma } from "../../shared/prisma";
export class InviteService {
async generateInviteToken(adminUserId: string): Promise<{ token: string; expiresAt: Date }> {
const token = randomBytes(32).toString("hex");
const expiresAt = new Date();
expiresAt.setMinutes(expiresAt.getMinutes() + 15);
await prisma.inviteToken.create({
data: {
token,
expiresAt,
createdBy: adminUserId,
},
});
return { token, expiresAt };
}
async validateInviteToken(token: string): Promise<{ valid: boolean; used?: boolean; expired?: boolean }> {
const inviteToken = await prisma.inviteToken.findUnique({
where: { token },
});
if (!inviteToken) {
return { valid: false };
}
if (inviteToken.usedAt) {
return { valid: false, used: true };
}
if (new Date() > inviteToken.expiresAt) {
return { valid: false, expired: true };
}
return { valid: true };
}
async registerWithInvite(data: {
token: string;
firstName: string;
lastName: string;
username: string;
email: string;
password: string;
}): Promise<{ id: string; username: string; email: string }> {
const validation = await this.validateInviteToken(data.token);
if (!validation.valid) {
if (validation.used) {
throw new Error("This invite link has already been used");
}
if (validation.expired) {
throw new Error("This invite link has expired");
}
throw new Error("Invalid invite link");
}
const existingUser = await prisma.user.findFirst({
where: {
OR: [{ username: data.username }, { email: data.email }],
},
});
if (existingUser) {
if (existingUser.username === data.username) {
throw new Error("Username already exists");
}
if (existingUser.email === data.email) {
throw new Error("Email already exists");
}
}
const hashedPassword = await bcrypt.hash(data.password, 10);
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
firstName: data.firstName,
lastName: data.lastName,
username: data.username,
email: data.email,
password: hashedPassword,
isAdmin: false,
isActive: true,
},
select: {
id: true,
username: true,
email: true,
},
});
await tx.inviteToken.update({
where: { token: data.token },
data: { usedAt: new Date() },
});
return user;
});
return result;
}
}

View File

@ -10,6 +10,7 @@ import { authRoutes } from "./modules/auth/routes";
import { fileRoutes } from "./modules/file/routes";
import { folderRoutes } from "./modules/folder/routes";
import { healthRoutes } from "./modules/health/routes";
import { inviteRoutes } from "./modules/invite/routes";
import { reverseShareRoutes } from "./modules/reverse-share/routes";
import { s3StorageRoutes } from "./modules/s3-storage/routes";
import { shareRoutes } from "./modules/share/routes";
@ -45,12 +46,7 @@ async function startServer() {
const app = await buildApp();
await ensureDirectories();
// Import storage config once at the beginning
const { isInternalStorage, isExternalS3 } = await import("./config/storage.config.js");
// Run automatic migration from legacy storage to S3-compatible storage
// Transparently migrates any existing files
const { runAutoMigration } = await import("./scripts/migrate-filesystem-to-s3.js");
await runAutoMigration();
@ -65,11 +61,10 @@ async function startServer() {
},
});
// No static files needed - S3 serves files directly via presigned URLs
app.register(authRoutes);
app.register(authProvidersRoutes, { prefix: "/auth" });
app.register(twoFactorRoutes, { prefix: "/auth" });
app.register(inviteRoutes);
app.register(userRoutes);
app.register(folderRoutes);
app.register(fileRoutes);
@ -78,8 +73,6 @@ async function startServer() {
app.register(storageRoutes);
app.register(appRoutes);
app.register(healthRoutes);
// Always use S3-compatible storage routes
app.register(s3StorageRoutes);
if (isInternalStorage) {

View File

@ -722,6 +722,49 @@
"createAdmin": "Create Admin Account"
}
},
"registerWithInvite": {
"title": "Create Your Account",
"description": "Fill in the information below to create your account",
"labels": {
"firstName": "First Name",
"firstNamePlaceholder": "Enter your first name",
"lastName": "Last Name",
"lastNamePlaceholder": "Enter your last name",
"username": "Username",
"usernamePlaceholder": "Choose a username",
"email": "Email",
"emailPlaceholder": "Enter your email",
"password": "Password",
"passwordPlaceholder": "Choose a password",
"confirmPassword": "Confirm Password",
"confirmPasswordPlaceholder": "Confirm your password"
},
"buttons": {
"creating": "Creating account...",
"createAccount": "Create Account"
},
"validation": {
"firstNameRequired": "First name is required",
"lastNameRequired": "Last name is required",
"usernameMinLength": "Username must be at least 3 characters",
"invalidEmail": "Invalid email",
"passwordMinLength": "Password must be at least 8 characters",
"passwordsMatch": "Passwords must match"
},
"messages": {
"success": "Account created successfully! Redirecting to login...",
"redirecting": "Redirecting to login..."
},
"errors": {
"invalidToken": "Error with invite link",
"tokenUsed": "This invite link has already been used",
"tokenExpired": "This invite link has expired",
"usernameExists": "Username already exists",
"emailExists": "Email already exists",
"createFailed": "Failed to create account. Please try again."
},
"pageTitle": "Create Account"
},
"resetPassword": {
"pageTitle": "Reset Password",
"header": {
@ -1909,6 +1952,23 @@
"inactive": "Inactive",
"admin": "Admin",
"userr": "User"
},
"invite": {
"button": "Generate Invite Link",
"title": "Generate User Invite Link",
"description": "Generate a one-time use link that allows someone to create their own account. The link expires in 15 minutes.",
"generating": "Generating...",
"generate": "Generate Link",
"generated": "Invite link generated successfully!",
"linkReady": "Invite link ready",
"linkReadyDescription": "Share this link with the person you want to invite. They'll be able to create their own account as a regular user.",
"copyLink": "Copy Link",
"linkCopied": "Invite link copied to clipboard!",
"expiresIn": "Expires in 15 minutes",
"close": "Close",
"errors": {
"generateFailed": "Failed to generate invite link"
}
}
},
"embedCode": {

View File

@ -1909,6 +1909,66 @@
"inactive": "Inativo",
"admin": "Administrador",
"userr": "Usuário"
},
"invite": {
"button": "Gerar Link de Convite",
"title": "Gerar Link de Convite de Usuário",
"description": "Gere um link de uso único que permite que alguém crie sua própria conta. O link expira em 15 minutos.",
"generating": "Gerando...",
"generate": "Gerar Link",
"generated": "Link gerado com sucesso!",
"linkReady": "Link de convite pronto",
"linkReadyDescription": "Compartilhe este link com a pessoa que você deseja convidar. Ela poderá criar sua própria conta como usuário regular.",
"copyLink": "Copiar Link",
"linkCopied": "Link de convite copiado para a área de transferência!",
"expiresIn": "Expira em 15 minutos",
"close": "Fechar",
"errors": {
"generateFailed": "Falha ao gerar link de convite"
}
}
},
"registerWithInvite": {
"pageTitle": "Criar Conta",
"title": "Crie Sua Conta",
"description": "Preencha as informações abaixo para criar sua conta",
"labels": {
"firstName": "Nome",
"firstNamePlaceholder": "Digite seu nome",
"lastName": "Sobrenome",
"lastNamePlaceholder": "Digite seu sobrenome",
"username": "Nome de Usuário",
"usernamePlaceholder": "Escolha um nome de usuário",
"email": "E-mail",
"emailPlaceholder": "Digite seu e-mail",
"password": "Senha",
"passwordPlaceholder": "Escolha uma senha",
"confirmPassword": "Confirmar Senha",
"confirmPasswordPlaceholder": "Confirme sua senha"
},
"buttons": {
"creating": "Criando conta...",
"createAccount": "Criar Conta"
},
"validation": {
"firstNameRequired": "Nome é obrigatório",
"lastNameRequired": "Sobrenome é obrigatório",
"usernameMinLength": "Nome de usuário deve ter pelo menos 3 caracteres",
"invalidEmail": "E-mail inválido",
"passwordMinLength": "Senha deve ter pelo menos 8 caracteres",
"passwordsMatch": "As senhas devem coincidir"
},
"messages": {
"success": "Conta criada com sucesso! Redirecionando para o login...",
"redirecting": "Redirecionando para o login..."
},
"errors": {
"invalidToken": "Link de convite inválido ou expirado",
"tokenUsed": "Este link de convite já foi usado",
"tokenExpired": "Este link de convite expirou",
"usernameExists": "Nome de usuário já existe",
"emailExists": "E-mail já existe",
"createFailed": "Falha ao criar conta. Por favor, tente novamente."
}
},
"validation": {
@ -1938,4 +1998,4 @@
"htmlDescription": "Use este código para incorporar a mídia em páginas HTML",
"bbcodeDescription": "Use este código para incorporar a mídia em fóruns que suportam BBCode"
}
}
}

View File

@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(req: NextRequest, { params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const url = `${API_BASE_URL}/invite-tokens/${token}`;
const apiRes = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
return res;
}

View File

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(req: NextRequest) {
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/invite-tokens`;
const apiRes = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
},
body: JSON.stringify({}),
redirect: "manual",
});
const resBody = await apiRes.json();
const res = NextResponse.json(resBody, {
status: apiRes.status,
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
}

View File

@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(req: NextRequest) {
const body = await req.text();
const url = `${API_BASE_URL}/register-with-invite`;
const apiRes = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body,
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
}

View File

@ -0,0 +1,18 @@
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
interface LayoutProps {
children: React.ReactNode;
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations();
return {
title: `${t("registerWithInvite.pageTitle")} `,
};
}
export default function RegisterWithInviteLayout({ children }: LayoutProps) {
return <>{children}</>;
}

View File

@ -0,0 +1,298 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { IconEye, IconEyeOff } from "@tabler/icons-react";
import { motion } from "framer-motion";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { StaticBackgroundLights } from "@/app/login/components/static-background-lights";
import { LanguageSwitcher } from "@/components/general/language-switcher";
import { LoadingScreen } from "@/components/layout/loading-screen";
import { Button } from "@/components/ui/button";
import { DefaultFooter } from "@/components/ui/default-footer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { registerWithInvite, validateInviteToken } from "@/http/endpoints/invite";
interface RegisterFormData {
firstName: string;
lastName: string;
username: string;
email: string;
password: string;
confirmPassword: string;
}
export default function RegisterWithInvitePage() {
const t = useTranslations();
const router = useRouter();
const params = useParams();
const token = params.token as string;
const [isValidating, setIsValidating] = useState(true);
const [tokenValid, setTokenValid] = useState(false);
const [tokenError, setTokenError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
watch,
} = useForm<RegisterFormData>();
const password = watch("password");
useEffect(() => {
const checkToken = async () => {
try {
const response = await validateInviteToken(token);
if (!response.valid) {
if (response.used) {
setTokenError(t("registerWithInvite.errors.tokenUsed"));
} else if (response.expired) {
setTokenError(t("registerWithInvite.errors.tokenExpired"));
} else {
setTokenError(t("registerWithInvite.errors.invalidToken"));
}
setTokenValid(false);
} else {
setTokenValid(true);
}
} catch (error) {
console.error("Error validating token:", error);
setTokenError(t("registerWithInvite.errors.invalidToken"));
setTokenValid(false);
} finally {
setIsValidating(false);
}
};
if (token) {
checkToken();
}
}, [token, t]);
const onSubmit = async (data: RegisterFormData) => {
if (data.password !== data.confirmPassword) {
toast.error(t("registerWithInvite.validation.passwordsMatch"));
return;
}
setIsSubmitting(true);
try {
await registerWithInvite({
token,
firstName: data.firstName,
lastName: data.lastName,
username: data.username,
email: data.email,
password: data.password,
});
toast.success(t("registerWithInvite.messages.success"));
setTimeout(() => {
router.push("/login");
}, 2000);
} catch (error: any) {
console.error("Error registering:", error);
const errorMessage = error.response?.data?.error;
if (errorMessage?.includes("already been used")) {
toast.error(t("registerWithInvite.errors.tokenUsed"));
} else if (errorMessage?.includes("expired")) {
toast.error(t("registerWithInvite.errors.tokenExpired"));
} else if (errorMessage?.includes("Username already exists")) {
toast.error(t("registerWithInvite.errors.usernameExists"));
} else if (errorMessage?.includes("Email already exists")) {
toast.error(t("registerWithInvite.errors.emailExists"));
} else {
toast.error(t("registerWithInvite.errors.createFailed"));
}
} finally {
setIsSubmitting(false);
}
};
if (isValidating) {
return <LoadingScreen />;
}
if (!tokenValid) {
return (
<div className="relative flex flex-col h-screen">
<div className="fixed top-4 right-4 z-50">
<LanguageSwitcher />
</div>
<div className="container mx-auto max-w-7xl px-6 flex-grow">
<StaticBackgroundLights />
<div className="relative flex h-full w-full items-center justify-center">
<motion.div
animate={{ opacity: 1, y: 0 }}
className="flex w-full max-w-sm flex-col gap-4 rounded-lg bg-background/60 backdrop-blur-md px-8 pb-10 pt-6 shadow-lg border"
initial={{ opacity: 0, y: 20 }}
transition={{ duration: 0.5 }}
>
<div className="text-center">
<h1 className="text-2xl font-bold mb-2">{t("registerWithInvite.errors.invalidToken")}</h1>
<p className="text-muted-foreground mb-4">{tokenError}</p>
<Button onClick={() => router.push("/login")}>{t("forgotPassword.backToLogin")}</Button>
</div>
</motion.div>
</div>
</div>
<DefaultFooter />
</div>
);
}
return (
<div className="relative flex flex-col h-screen">
<div className="fixed top-4 right-4 z-50">
<LanguageSwitcher />
</div>
<div className="container mx-auto max-w-7xl px-6 flex-grow">
<StaticBackgroundLights />
<div className="relative flex h-full w-full items-center justify-center">
<motion.div
animate={{ opacity: 1, y: 0 }}
className="flex w-full max-w-md flex-col gap-6 rounded-lg bg-background/60 backdrop-blur-md px-8 pb-10 pt-6 shadow-lg border"
initial={{ opacity: 0, y: 20 }}
transition={{ duration: 0.5 }}
>
<div className="text-center">
<h1 className="text-2xl font-bold">{t("registerWithInvite.title")}</h1>
<p className="text-muted-foreground text-sm mt-2">{t("registerWithInvite.description")}</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">{t("registerWithInvite.labels.firstName")}</Label>
<Input
id="firstName"
placeholder={t("registerWithInvite.labels.firstNamePlaceholder")}
{...register("firstName", {
required: t("registerWithInvite.validation.firstNameRequired"),
})}
/>
{errors.firstName && <p className="text-destructive text-sm">{errors.firstName.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="lastName">{t("registerWithInvite.labels.lastName")}</Label>
<Input
id="lastName"
placeholder={t("registerWithInvite.labels.lastNamePlaceholder")}
{...register("lastName", {
required: t("registerWithInvite.validation.lastNameRequired"),
})}
/>
{errors.lastName && <p className="text-destructive text-sm">{errors.lastName.message}</p>}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="username">{t("registerWithInvite.labels.username")}</Label>
<Input
id="username"
placeholder={t("registerWithInvite.labels.usernamePlaceholder")}
{...register("username", {
required: t("registerWithInvite.validation.usernameMinLength"),
minLength: {
value: 3,
message: t("registerWithInvite.validation.usernameMinLength"),
},
})}
/>
{errors.username && <p className="text-destructive text-sm">{errors.username.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="email">{t("registerWithInvite.labels.email")}</Label>
<Input
id="email"
type="email"
placeholder={t("registerWithInvite.labels.emailPlaceholder")}
{...register("email", {
required: t("registerWithInvite.validation.invalidEmail"),
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: t("registerWithInvite.validation.invalidEmail"),
},
})}
/>
{errors.email && <p className="text-destructive text-sm">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="password">{t("registerWithInvite.labels.password")}</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder={t("registerWithInvite.labels.passwordPlaceholder")}
{...register("password", {
required: t("registerWithInvite.validation.passwordMinLength"),
minLength: {
value: 8,
message: t("registerWithInvite.validation.passwordMinLength"),
},
})}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <IconEyeOff size={18} /> : <IconEye size={18} />}
</button>
</div>
{errors.password && <p className="text-destructive text-sm">{errors.password.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">{t("registerWithInvite.labels.confirmPassword")}</Label>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
placeholder={t("registerWithInvite.labels.confirmPasswordPlaceholder")}
{...register("confirmPassword", {
required: t("registerWithInvite.validation.passwordsMatch"),
validate: (value) => value === password || t("registerWithInvite.validation.passwordsMatch"),
})}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showConfirmPassword ? <IconEyeOff size={18} /> : <IconEye size={18} />}
</button>
</div>
{errors.confirmPassword && <p className="text-destructive text-sm">{errors.confirmPassword.message}</p>}
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting
? t("registerWithInvite.buttons.creating")
: t("registerWithInvite.buttons.createAccount")}
</Button>
</form>
</motion.div>
</div>
</div>
<DefaultFooter />
</div>
);
}

View File

@ -0,0 +1,106 @@
"use client";
import { useState } from "react";
import { IconCheck, IconCopy, IconLink } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { generateInviteToken } from "@/http/endpoints/invite";
interface GenerateInviteLinkModalProps {
isOpen: boolean;
onClose: () => void;
}
export function GenerateInviteLinkModal({ isOpen, onClose }: GenerateInviteLinkModalProps) {
const t = useTranslations();
const [isGenerating, setIsGenerating] = useState(false);
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const handleGenerate = async () => {
setIsGenerating(true);
try {
const response = await generateInviteToken();
const inviteUrl = `${window.location.origin}/register-with-invite/${response.token}`;
setInviteUrl(inviteUrl);
toast.success(t("users.invite.generated"));
} catch (error) {
console.error("Failed to generate invite token:", error);
toast.error(t("users.invite.errors.generateFailed"));
} finally {
setIsGenerating(false);
}
};
const handleCopy = async () => {
if (!inviteUrl) return;
try {
await navigator.clipboard.writeText(inviteUrl);
setCopied(true);
toast.success(t("users.invite.linkCopied"));
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Failed to copy:", error);
toast.error("Failed to copy link");
}
};
const handleClose = () => {
setInviteUrl(null);
setCopied(false);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconLink size={24} />
{t("users.invite.title")}
</DialogTitle>
<DialogDescription>{t("users.invite.description")}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{!inviteUrl ? (
<Button onClick={handleGenerate} disabled={isGenerating} className="w-full">
{isGenerating ? t("users.invite.generating") : t("users.invite.generate")}
</Button>
) : (
<div className="space-y-4">
<div className="rounded-lg border bg-muted/50 p-4">
<h4 className="mb-2 font-semibold text-sm">{t("users.invite.linkReady")}</h4>
<p className="mb-4 text-muted-foreground text-sm">{t("users.invite.linkReadyDescription")}</p>
<div className="space-y-2">
<Label htmlFor="invite-url">{t("users.invite.copyLink")}</Label>
<div className="flex gap-2">
<Input id="invite-url" value={inviteUrl} readOnly className="font-mono text-sm" />
<Button type="button" variant="outline" size="icon" onClick={handleCopy} className="shrink-0">
{copied ? <IconCheck size={18} /> : <IconCopy size={18} />}
</Button>
</div>
<p className="text-muted-foreground text-xs">{t("users.invite.expiresIn")}</p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleClose}>
{t("users.invite.close")}
</Button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,5 +1,5 @@
import Link from "next/link";
import { IconLayoutDashboard, IconUserPlus, IconUsers } from "@tabler/icons-react";
import { IconLayoutDashboard, IconLink, IconUserPlus, IconUsers } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import {
@ -13,7 +13,7 @@ import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { UsersHeaderProps } from "../types";
export function UsersHeader({ onCreateUser }: UsersHeaderProps) {
export function UsersHeader({ onCreateUser, onGenerateInvite }: UsersHeaderProps) {
const t = useTranslations();
return (
@ -23,10 +23,16 @@ export function UsersHeader({ onCreateUser }: UsersHeaderProps) {
<IconUsers className="text-2xl" />
<h1 className="text-2xl font-bold">{t("users.header.title")}</h1>
</div>
<Button className="font-semibold" onClick={onCreateUser}>
<IconUserPlus size={18} />
{t("users.header.addUser")}
</Button>
<div className="flex gap-2">
<Button variant="outline" className="font-semibold" onClick={onGenerateInvite}>
<IconLink size={18} />
{t("users.invite.button")}
</Button>
<Button className="font-semibold" onClick={onCreateUser}>
<IconUserPlus size={18} />
{t("users.header.addUser")}
</Button>
</div>
</div>
<Separator />
<Breadcrumb>

View File

@ -1,9 +1,12 @@
"use client";
import { useState } from "react";
import { ProtectedRoute } from "@/components/auth/protected-route";
import { LoadingScreen } from "@/components/layout/loading-screen";
import { Navbar } from "@/components/layout/navbar";
import { DefaultFooter } from "@/components/ui/default-footer";
import { GenerateInviteLinkModal } from "./components/generate-invite-link-modal";
import { UserManagementModals } from "./components/user-management-modals";
import { UsersHeader } from "./components/users-header";
import { UsersTable } from "./components/users-table";
@ -26,6 +29,8 @@ export default function AdminAreaPage() {
formMethods,
} = useUserManagement();
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
if (isLoading) {
return <LoadingScreen />;
}
@ -36,7 +41,7 @@ export default function AdminAreaPage() {
<Navbar />
<div className="flex-1 max-w-7xl mx-auto w-full py-8 px-6">
<div className="flex flex-col gap-8">
<UsersHeader onCreateUser={handleCreateUser} />
<UsersHeader onCreateUser={handleCreateUser} onGenerateInvite={() => setIsInviteModalOpen(true)} />
<UsersTable
currentUser={currentUser}
@ -65,6 +70,8 @@ export default function AdminAreaPage() {
onSubmit={onSubmit}
onToggleStatus={handleToggleUserStatus}
/>
<GenerateInviteLinkModal isOpen={isInviteModalOpen} onClose={() => setIsInviteModalOpen(false)} />
</div>
</ProtectedRoute>
);

View File

@ -55,6 +55,7 @@ export interface UserStatusModalProps {
export interface UsersHeaderProps {
onCreateUser: () => void;
onGenerateInvite: () => void;
}
export interface AuthUser {

View File

@ -0,0 +1,10 @@
export const publicPaths = [
"/login",
"/forgot-password",
"/reset-password",
"/auth/callback",
"/auth/oidc/callback",
"/register-with-invite",
"/s/",
"/r/",
];

View File

@ -0,0 +1,8 @@
export const unauthenticatedOnlyPaths = [
"/login",
"/forgot-password",
"/reset-password",
"/auth/callback",
"/auth/oidc/callback",
"/register-with-invite",
];

View File

@ -3,6 +3,8 @@
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { publicPaths } from "@/components/auth/paths/public-paths";
import { unauthenticatedOnlyPaths } from "@/components/auth/paths/unahthenticated-only-paths";
import { LoadingScreen } from "@/components/layout/loading-screen";
import { useAuth } from "@/contexts/auth-context";
@ -10,23 +12,6 @@ interface RedirectHandlerProps {
children: React.ReactNode;
}
const publicPaths = [
"/login",
"/forgot-password",
"/reset-password",
"/auth/callback",
"/auth/oidc/callback",
"/s/",
"/r/",
];
const unauthenticatedOnlyPaths = [
"/login",
"/forgot-password",
"/reset-password",
"/auth/callback",
"/auth/oidc/callback",
];
const homePaths = ["/"];
export function RedirectHandler({ children }: RedirectHandlerProps) {

View File

@ -7,3 +7,4 @@ export * from "./reverse-shares";
export * from "./config";
export * from "./app";
export * from "./auth/trusted-devices";
export * from "./invite";

View File

@ -0,0 +1,31 @@
import type { AxiosRequestConfig } from "axios";
import apiInstance from "@/config/api";
import type {
GenerateInviteTokenResponse,
RegisterWithInviteRequest,
RegisterWithInviteResponse,
ValidateInviteTokenResponse,
} from "./types";
export const generateInviteToken = async <TData = GenerateInviteTokenResponse>(
options?: AxiosRequestConfig
): Promise<TData> => {
const response = await apiInstance.post(`/api/invite-tokens`, undefined, options);
return response.data;
};
export const validateInviteToken = async <TData = ValidateInviteTokenResponse>(
token: string,
options?: AxiosRequestConfig
): Promise<TData> => {
const response = await apiInstance.get(`/api/invite-tokens/${token}`, options);
return response.data;
};
export const registerWithInvite = <TData = RegisterWithInviteResponse>(
data: RegisterWithInviteRequest,
options?: AxiosRequestConfig
): Promise<TData> => {
return apiInstance.post(`/api/register-with-invite`, data, options);
};

View File

@ -0,0 +1,28 @@
export interface GenerateInviteTokenResponse {
token: string;
expiresAt: string;
}
export interface ValidateInviteTokenResponse {
valid: boolean;
used?: boolean;
expired?: boolean;
}
export interface RegisterWithInviteRequest {
token: string;
firstName: string;
lastName: string;
username: string;
email: string;
password: string;
}
export interface RegisterWithInviteResponse {
message: string;
user: {
id: string;
username: string;
email: string;
};
}