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
|
// 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
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