From d0ca0b9d7deda0a0b59ba4af2bc1b13b0f737998 Mon Sep 17 00:00:00 2001 From: Flux_bot Date: Wed, 25 Feb 2026 10:03:32 +0000 Subject: [PATCH] feat: add user management API (create, update, delete, reset-password) - New /api/v1/users endpoints for team management - Role-based permissions (owner can do all, admin can manage managers/users) - Role hierarchy: owner (CEO) > admin > manager > user - Soft delete with token revocation --- src/main.ts | 12 + src/routes/users.ts | 602 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 614 insertions(+) create mode 100644 src/routes/users.ts diff --git a/src/main.ts b/src/main.ts index d8bcf34..acf83f2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,7 @@ import "@std/dotenv/load"; // Routes import { authRouter } from "./routes/auth.ts"; +import { usersRouter } from "./routes/users.ts"; import { contactsRouter } from "./routes/contacts.ts"; import { companiesRouter } from "./routes/companies.ts"; import { dealsRouter } from "./routes/deals.ts"; @@ -139,6 +140,14 @@ app.use(async (ctx, next) => { "POST /api/v1/auth/verify-email": "Verify email address", "GET /api/v1/auth/me": "Get current user", }, + users: { + "GET /api/v1/users": "List organization users (admin/owner)", + "GET /api/v1/users/:id": "Get user details", + "POST /api/v1/users": "Create/invite new user (admin/owner)", + "PUT /api/v1/users/:id": "Update user (admin/owner)", + "DELETE /api/v1/users/:id": "Delete user (admin/owner)", + "POST /api/v1/users/:id/reset-password": "Reset user password (admin/owner)", + }, contacts: { "GET /api/v1/contacts": "List contacts", "GET /api/v1/contacts/stats": "Contact statistics", @@ -214,6 +223,9 @@ app.use(async (ctx, next) => { app.use(authRouter.routes()); app.use(authRouter.allowedMethods()); +app.use(usersRouter.routes()); +app.use(usersRouter.allowedMethods()); + app.use(contactsRouter.routes()); app.use(contactsRouter.allowedMethods()); diff --git a/src/routes/users.ts b/src/routes/users.ts new file mode 100644 index 0000000..69012f5 --- /dev/null +++ b/src/routes/users.ts @@ -0,0 +1,602 @@ +import { Router } from "@oak/oak"; +import { z } from "zod"; +import * as userRepo from "../repositories/user.ts"; +import * as passwordService from "../services/password.ts"; +import { requireAuth, requireRole } from "../middleware/auth.ts"; +import type { AuthState, UserPublic, UserRole } from "../types/index.ts"; +import { query, queryOne, execute } from "../db/connection.ts"; + +const router = new Router({ prefix: "/api/v1/users" }); + +// ============================================ +// VALIDATION SCHEMAS +// ============================================ + +const createUserSchema = z.object({ + email: z.string().email("Invalid email address"), + password: z.string().min(8, "Password must be at least 8 characters"), + firstName: z.string().min(1, "First name is required").max(50), + lastName: z.string().min(1, "Last name is required").max(50), + role: z.enum(["admin", "manager", "user"]), +}); + +const updateUserSchema = z.object({ + firstName: z.string().min(1).max(50).optional(), + lastName: z.string().min(1).max(50).optional(), + role: z.enum(["admin", "manager", "user"]).optional(), + isActive: z.boolean().optional(), +}); + +// ============================================ +// HELPER FUNCTIONS +// ============================================ + +function toUserPublic(user: { + id: string; + email: string; + first_name: string; + last_name: string; + role: string; + is_verified: boolean; + is_active: boolean; + org_id: string; + two_factor_enabled: boolean; + created_at: Date; + last_login_at?: Date | null; +}): UserPublic & { isActive: boolean; lastLoginAt: string | null } { + return { + id: user.id, + email: user.email, + firstName: user.first_name, + lastName: user.last_name, + role: user.role as UserRole, + isVerified: user.is_verified, + isActive: user.is_active, + orgId: user.org_id, + twoFactorEnabled: user.two_factor_enabled, + createdAt: user.created_at.toISOString(), + lastLoginAt: user.last_login_at?.toISOString() || null, + }; +} + +// Role hierarchy for permission checks +const ROLE_HIERARCHY: Record = { + owner: 4, // CEO - highest + admin: 3, // CRM Admin + manager: 2, + user: 1, // lowest +}; + +function canManageRole(currentRole: UserRole, targetRole: UserRole): boolean { + return ROLE_HIERARCHY[currentRole] > ROLE_HIERARCHY[targetRole]; +} + +// ============================================ +// ROUTES +// ============================================ + +// GET /api/v1/users - List all users in organization +// Access: owner, admin +router.get("/", requireAuth, requireRole("owner", "admin"), async (ctx) => { + const orgId = ctx.state.orgId; + + const users = await query<{ + id: string; + email: string; + first_name: string; + last_name: string; + role: string; + is_verified: boolean; + is_active: boolean; + org_id: string; + two_factor_enabled: boolean; + created_at: Date; + last_login_at: Date | null; + }>( + `SELECT id, email, first_name, last_name, role, is_verified, is_active, + org_id, two_factor_enabled, created_at, last_login_at + FROM users + WHERE org_id = $1 AND deleted_at IS NULL + ORDER BY created_at ASC`, + [orgId] + ); + + ctx.response.body = { + success: true, + data: { + users: users.map(toUserPublic), + total: users.length, + }, + }; +}); + +// GET /api/v1/users/:id - Get single user +// Access: owner, admin (or self) +router.get("/:id", requireAuth, async (ctx) => { + const userId = ctx.params.id; + const currentUser = ctx.state.user; + const orgId = ctx.state.orgId; + + // Allow users to view themselves, or admins/owners to view anyone in org + const isOwnProfile = userId === currentUser.id; + const isAdminOrOwner = currentUser.role === "owner" || currentUser.role === "admin"; + + if (!isOwnProfile && !isAdminOrOwner) { + ctx.response.status = 403; + ctx.response.body = { + success: false, + error: { + code: "FORBIDDEN", + message: "You can only view your own profile", + }, + }; + return; + } + + const user = await queryOne<{ + id: string; + email: string; + first_name: string; + last_name: string; + role: string; + is_verified: boolean; + is_active: boolean; + org_id: string; + two_factor_enabled: boolean; + created_at: Date; + last_login_at: Date | null; + }>( + `SELECT id, email, first_name, last_name, role, is_verified, is_active, + org_id, two_factor_enabled, created_at, last_login_at + FROM users + WHERE id = $1 AND org_id = $2 AND deleted_at IS NULL`, + [userId, orgId] + ); + + if (!user) { + ctx.response.status = 404; + ctx.response.body = { + success: false, + error: { + code: "USER_NOT_FOUND", + message: "User not found", + }, + }; + return; + } + + ctx.response.body = { + success: true, + data: { user: toUserPublic(user) }, + }; +}); + +// POST /api/v1/users - Create new user (invite) +// Access: owner, admin +router.post("/", requireAuth, requireRole("owner", "admin"), async (ctx) => { + const body = await ctx.request.body.json(); + const currentUser = ctx.state.user; + const orgId = ctx.state.orgId; + + // Validate input + const result = createUserSchema.safeParse(body); + if (!result.success) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { + code: "VALIDATION_ERROR", + message: "Invalid input", + details: result.error.errors, + }, + }; + return; + } + + const { email, password, firstName, lastName, role } = result.data; + + // Check if admin is trying to create someone with higher/equal role + // Only owner can create admins + if (role === "admin" && currentUser.role !== "owner") { + ctx.response.status = 403; + ctx.response.body = { + success: false, + error: { + code: "FORBIDDEN", + message: "Only the CEO/Owner can create admin users", + }, + }; + return; + } + + // Check if email already exists + const existingUser = await userRepo.findByEmail(email); + if (existingUser) { + ctx.response.status = 409; + ctx.response.body = { + success: false, + error: { + code: "EMAIL_EXISTS", + message: "A user with this email already exists", + }, + }; + return; + } + + // Check organization user limit + const orgInfo = await queryOne<{ plan: string; max_users: number }>( + `SELECT plan, max_users FROM organizations WHERE id = $1`, + [orgId] + ); + + const userCount = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM users WHERE org_id = $1 AND deleted_at IS NULL`, + [orgId] + ); + + if (orgInfo && parseInt(userCount?.count || "0") >= orgInfo.max_users) { + ctx.response.status = 403; + ctx.response.body = { + success: false, + error: { + code: "USER_LIMIT_REACHED", + message: `Your plan allows a maximum of ${orgInfo.max_users} users. Please upgrade to add more.`, + }, + }; + return; + } + + // Check password strength + const passwordCheck = passwordService.validatePasswordStrength(password); + if (!passwordCheck.valid) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { + code: "WEAK_PASSWORD", + message: "Password does not meet requirements", + details: passwordCheck.errors, + }, + }; + return; + } + + // Hash password and create user + const passwordHash = await passwordService.hashPassword(password); + + const newUser = await userRepo.createUser({ + orgId, + email, + passwordHash, + firstName, + lastName, + role: role as "admin" | "manager" | "user", + isVerified: true, // Auto-verify invited users + }); + + ctx.response.status = 201; + ctx.response.body = { + success: true, + message: "User created successfully", + data: { + user: { + id: newUser.id, + email: newUser.email, + firstName: newUser.first_name, + lastName: newUser.last_name, + role: newUser.role, + isVerified: newUser.is_verified, + isActive: newUser.is_active, + orgId: newUser.org_id, + createdAt: newUser.created_at.toISOString(), + }, + }, + }; +}); + +// PUT /api/v1/users/:id - Update user +// Access: owner (all), admin (managers/users only) +router.put("/:id", requireAuth, requireRole("owner", "admin"), async (ctx) => { + const userId = ctx.params.id; + const body = await ctx.request.body.json(); + const currentUser = ctx.state.user; + const orgId = ctx.state.orgId; + + // Validate input + const result = updateUserSchema.safeParse(body); + if (!result.success) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { + code: "VALIDATION_ERROR", + message: "Invalid input", + details: result.error.errors, + }, + }; + return; + } + + // Get target user + const targetUser = await queryOne<{ role: string; org_id: string }>( + `SELECT role, org_id FROM users WHERE id = $1 AND deleted_at IS NULL`, + [userId] + ); + + if (!targetUser || targetUser.org_id !== orgId) { + ctx.response.status = 404; + ctx.response.body = { + success: false, + error: { + code: "USER_NOT_FOUND", + message: "User not found", + }, + }; + return; + } + + // Permission check: can't edit users with same or higher role (unless owner) + if (currentUser.role !== "owner" && !canManageRole(currentUser.role as UserRole, targetUser.role as UserRole)) { + ctx.response.status = 403; + ctx.response.body = { + success: false, + error: { + code: "FORBIDDEN", + message: "You cannot modify users with the same or higher role", + }, + }; + return; + } + + // Can't change someone to a role higher than your own (except owner) + if (result.data.role && currentUser.role !== "owner" && result.data.role === "admin") { + ctx.response.status = 403; + ctx.response.body = { + success: false, + error: { + code: "FORBIDDEN", + message: "Only the CEO/Owner can promote users to admin", + }, + }; + return; + } + + // Can't demote or deactivate the owner + if (targetUser.role === "owner") { + ctx.response.status = 403; + ctx.response.body = { + success: false, + error: { + code: "FORBIDDEN", + message: "Cannot modify the organization owner", + }, + }; + return; + } + + // Build update query + const updates: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + if (result.data.firstName !== undefined) { + updates.push(`first_name = $${paramIndex++}`); + values.push(result.data.firstName); + } + if (result.data.lastName !== undefined) { + updates.push(`last_name = $${paramIndex++}`); + values.push(result.data.lastName); + } + if (result.data.role !== undefined) { + updates.push(`role = $${paramIndex++}`); + values.push(result.data.role); + } + if (result.data.isActive !== undefined) { + updates.push(`is_active = $${paramIndex++}`); + values.push(result.data.isActive); + } + + if (updates.length === 0) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { + code: "NO_CHANGES", + message: "No changes provided", + }, + }; + return; + } + + updates.push(`updated_at = NOW()`); + values.push(userId, orgId); + + await execute( + `UPDATE users SET ${updates.join(", ")} WHERE id = $${paramIndex++} AND org_id = $${paramIndex}`, + values + ); + + // Fetch updated user + const updatedUser = await queryOne<{ + id: string; + email: string; + first_name: string; + last_name: string; + role: string; + is_verified: boolean; + is_active: boolean; + org_id: string; + two_factor_enabled: boolean; + created_at: Date; + last_login_at: Date | null; + }>( + `SELECT id, email, first_name, last_name, role, is_verified, is_active, + org_id, two_factor_enabled, created_at, last_login_at + FROM users WHERE id = $1`, + [userId] + ); + + ctx.response.body = { + success: true, + message: "User updated successfully", + data: { user: updatedUser ? toUserPublic(updatedUser) : null }, + }; +}); + +// DELETE /api/v1/users/:id - Soft delete user +// Access: owner (all), admin (managers/users only) +router.delete("/:id", requireAuth, requireRole("owner", "admin"), async (ctx) => { + const userId = ctx.params.id; + const currentUser = ctx.state.user; + const orgId = ctx.state.orgId; + + // Can't delete yourself + if (userId === currentUser.id) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { + code: "CANNOT_DELETE_SELF", + message: "You cannot delete your own account", + }, + }; + return; + } + + // Get target user + const targetUser = await queryOne<{ role: string; org_id: string }>( + `SELECT role, org_id FROM users WHERE id = $1 AND deleted_at IS NULL`, + [userId] + ); + + if (!targetUser || targetUser.org_id !== orgId) { + ctx.response.status = 404; + ctx.response.body = { + success: false, + error: { + code: "USER_NOT_FOUND", + message: "User not found", + }, + }; + return; + } + + // Can't delete the owner + if (targetUser.role === "owner") { + ctx.response.status = 403; + ctx.response.body = { + success: false, + error: { + code: "FORBIDDEN", + message: "Cannot delete the organization owner", + }, + }; + return; + } + + // Permission check + if (currentUser.role !== "owner" && !canManageRole(currentUser.role as UserRole, targetUser.role as UserRole)) { + ctx.response.status = 403; + ctx.response.body = { + success: false, + error: { + code: "FORBIDDEN", + message: "You cannot delete users with the same or higher role", + }, + }; + return; + } + + // Soft delete + await execute( + `UPDATE users SET deleted_at = NOW(), is_active = false WHERE id = $1`, + [userId] + ); + + // Revoke all refresh tokens + await userRepo.revokeAllUserTokens(userId); + + ctx.response.body = { + success: true, + message: "User deleted successfully", + }; +}); + +// POST /api/v1/users/:id/reset-password - Admin reset user password +// Access: owner, admin +router.post("/:id/reset-password", requireAuth, requireRole("owner", "admin"), async (ctx) => { + const userId = ctx.params.id; + const body = await ctx.request.body.json(); + const currentUser = ctx.state.user; + const orgId = ctx.state.orgId; + + const { password } = body; + if (!password || password.length < 8) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { + code: "VALIDATION_ERROR", + message: "Password must be at least 8 characters", + }, + }; + return; + } + + // Get target user + const targetUser = await queryOne<{ role: string; org_id: string }>( + `SELECT role, org_id FROM users WHERE id = $1 AND deleted_at IS NULL`, + [userId] + ); + + if (!targetUser || targetUser.org_id !== orgId) { + ctx.response.status = 404; + ctx.response.body = { + success: false, + error: { + code: "USER_NOT_FOUND", + message: "User not found", + }, + }; + return; + } + + // Permission check + if (targetUser.role === "owner" && currentUser.role !== "owner") { + ctx.response.status = 403; + ctx.response.body = { + success: false, + error: { + code: "FORBIDDEN", + message: "Only the owner can reset their own password", + }, + }; + return; + } + + if (currentUser.role !== "owner" && !canManageRole(currentUser.role as UserRole, targetUser.role as UserRole)) { + ctx.response.status = 403; + ctx.response.body = { + success: false, + error: { + code: "FORBIDDEN", + message: "You cannot reset passwords for users with the same or higher role", + }, + }; + return; + } + + // Hash and update password + const passwordHash = await passwordService.hashPassword(password); + await userRepo.updatePassword(userId, passwordHash); + + // Revoke all refresh tokens (force re-login) + await userRepo.revokeAllUserTokens(userId); + + ctx.response.body = { + success: true, + message: "Password reset successfully. User will need to log in again.", + }; +}); + +export { router as usersRouter };