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:
12
src/main.ts
12
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());
|
||||
|
||||
|
||||
602
src/routes/users.ts
Normal file
602
src/routes/users.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user