mirror of
https://github.com/kyantech/Palmr.git
synced 2026-01-09 06:02:28 +08:00
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:
parent
3dbd5b81ae
commit
b58121b337
@ -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")
|
||||
}
|
||||
|
||||
87
apps/server/src/modules/invite/controller.ts
Normal file
87
apps/server/src/modules/invite/controller.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
30
apps/server/src/modules/invite/dto.ts
Normal file
30
apps/server/src/modules/invite/dto.ts
Normal 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"),
|
||||
}),
|
||||
});
|
||||
79
apps/server/src/modules/invite/routes.ts
Normal file
79
apps/server/src/modules/invite/routes.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
107
apps/server/src/modules/invite/service.ts
Normal file
107
apps/server/src/modules/invite/service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
27
apps/web/src/app/api/(proxy)/invite-tokens/[token]/route.ts
Normal file
27
apps/web/src/app/api/(proxy)/invite-tokens/[token]/route.ts
Normal 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;
|
||||
}
|
||||
31
apps/web/src/app/api/(proxy)/invite-tokens/route.ts
Normal file
31
apps/web/src/app/api/(proxy)/invite-tokens/route.ts
Normal 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;
|
||||
}
|
||||
33
apps/web/src/app/api/(proxy)/register-with-invite/route.ts
Normal file
33
apps/web/src/app/api/(proxy)/register-with-invite/route.ts
Normal 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;
|
||||
}
|
||||
18
apps/web/src/app/register-with-invite/[token]/layout.tsx
Normal file
18
apps/web/src/app/register-with-invite/[token]/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
298
apps/web/src/app/register-with-invite/[token]/page.tsx
Normal file
298
apps/web/src/app/register-with-invite/[token]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -55,6 +55,7 @@ export interface UserStatusModalProps {
|
||||
|
||||
export interface UsersHeaderProps {
|
||||
onCreateUser: () => void;
|
||||
onGenerateInvite: () => void;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
|
||||
10
apps/web/src/components/auth/paths/public-paths.ts
Normal file
10
apps/web/src/components/auth/paths/public-paths.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export const publicPaths = [
|
||||
"/login",
|
||||
"/forgot-password",
|
||||
"/reset-password",
|
||||
"/auth/callback",
|
||||
"/auth/oidc/callback",
|
||||
"/register-with-invite",
|
||||
"/s/",
|
||||
"/r/",
|
||||
];
|
||||
@ -0,0 +1,8 @@
|
||||
export const unauthenticatedOnlyPaths = [
|
||||
"/login",
|
||||
"/forgot-password",
|
||||
"/reset-password",
|
||||
"/auth/callback",
|
||||
"/auth/oidc/callback",
|
||||
"/register-with-invite",
|
||||
];
|
||||
@ -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) {
|
||||
|
||||
@ -7,3 +7,4 @@ export * from "./reverse-shares";
|
||||
export * from "./config";
|
||||
export * from "./app";
|
||||
export * from "./auth/trusted-devices";
|
||||
export * from "./invite";
|
||||
|
||||
31
apps/web/src/http/endpoints/invite/index.ts
Normal file
31
apps/web/src/http/endpoints/invite/index.ts
Normal 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);
|
||||
};
|
||||
28
apps/web/src/http/endpoints/invite/types.ts
Normal file
28
apps/web/src/http/endpoints/invite/types.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user