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
This commit is contained in:
2026-02-25 10:03:32 +00:00
parent 6276aed795
commit d0ca0b9d7d
2 changed files with 614 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ import "@std/dotenv/load";
// Routes // Routes
import { authRouter } from "./routes/auth.ts"; import { authRouter } from "./routes/auth.ts";
import { usersRouter } from "./routes/users.ts";
import { contactsRouter } from "./routes/contacts.ts"; import { contactsRouter } from "./routes/contacts.ts";
import { companiesRouter } from "./routes/companies.ts"; import { companiesRouter } from "./routes/companies.ts";
import { dealsRouter } from "./routes/deals.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", "POST /api/v1/auth/verify-email": "Verify email address",
"GET /api/v1/auth/me": "Get current user", "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: { contacts: {
"GET /api/v1/contacts": "List contacts", "GET /api/v1/contacts": "List contacts",
"GET /api/v1/contacts/stats": "Contact statistics", "GET /api/v1/contacts/stats": "Contact statistics",
@@ -214,6 +223,9 @@ app.use(async (ctx, next) => {
app.use(authRouter.routes()); app.use(authRouter.routes());
app.use(authRouter.allowedMethods()); app.use(authRouter.allowedMethods());
app.use(usersRouter.routes());
app.use(usersRouter.allowedMethods());
app.use(contactsRouter.routes()); app.use(contactsRouter.routes());
app.use(contactsRouter.allowedMethods()); app.use(contactsRouter.allowedMethods());

602
src/routes/users.ts Normal file
View File

@@ -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<AuthState>({ 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<UserRole, number> = {
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 };