mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: implement admin user management and invite system
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
import { Env, User, Invite } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
|
||||
function isAdmin(user: User): boolean {
|
||||
return user.role === 'admin' && user.status === 'active';
|
||||
}
|
||||
|
||||
function randomHex(bytes: number): string {
|
||||
const data = crypto.getRandomValues(new Uint8Array(bytes));
|
||||
return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function buildInviteLink(request: Request, code: string): string {
|
||||
const url = new URL(request.url);
|
||||
return `${url.origin}/?invite=${encodeURIComponent(code)}`;
|
||||
}
|
||||
|
||||
async function writeAuditLog(
|
||||
storage: StorageService,
|
||||
actorUserId: string | null,
|
||||
action: string,
|
||||
targetType: string | null,
|
||||
targetId: string | null,
|
||||
metadata: Record<string, unknown> | null
|
||||
): Promise<void> {
|
||||
await storage.createAuditLog({
|
||||
id: generateUUID(),
|
||||
actorUserId,
|
||||
action,
|
||||
targetType,
|
||||
targetId,
|
||||
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function toInviteResponse(request: Request, invite: Invite): Record<string, unknown> {
|
||||
return {
|
||||
code: invite.code,
|
||||
status: invite.status,
|
||||
createdBy: invite.createdBy,
|
||||
usedBy: invite.usedBy,
|
||||
createdAt: invite.createdAt,
|
||||
updatedAt: invite.updatedAt,
|
||||
expiresAt: invite.expiresAt,
|
||||
inviteLink: buildInviteLink(request, invite.code),
|
||||
object: 'invite',
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/admin/users
|
||||
export async function handleAdminListUsers(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const users = await storage.getAllUsers();
|
||||
return jsonResponse({
|
||||
data: users.map(user => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
creationDate: user.createdAt,
|
||||
revisionDate: user.updatedAt,
|
||||
object: 'user',
|
||||
})),
|
||||
object: 'list',
|
||||
continuationToken: null,
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/admin/invites
|
||||
export async function handleAdminCreateInvite(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
let body: { expiresInHours?: number } = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
|
||||
const expiresInHours = Number.isFinite(body.expiresInHours)
|
||||
? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours))))
|
||||
: 24 * 7;
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + expiresInHours * 60 * 60 * 1000);
|
||||
const invite: Invite = {
|
||||
code: randomHex(20),
|
||||
createdBy: actorUser.id,
|
||||
usedBy: null,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
status: 'active',
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
};
|
||||
|
||||
await storage.createInvite(invite);
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', invite.code, {
|
||||
expiresInHours,
|
||||
});
|
||||
|
||||
return jsonResponse(toInviteResponse(request, invite), 201);
|
||||
}
|
||||
|
||||
// GET /api/admin/invites
|
||||
export async function handleAdminListInvites(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const url = new URL(request.url);
|
||||
const includeInactive = url.searchParams.get('includeInactive') === 'true';
|
||||
const invites = await storage.listInvites(includeInactive);
|
||||
return jsonResponse({
|
||||
data: invites.map(invite => toInviteResponse(request, invite)),
|
||||
object: 'list',
|
||||
continuationToken: null,
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE /api/admin/invites/:code
|
||||
export async function handleAdminRevokeInvite(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User,
|
||||
code: string
|
||||
): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const revoked = await storage.revokeInvite(code);
|
||||
if (!revoked) {
|
||||
return errorResponse('Invite not found or already inactive', 404);
|
||||
}
|
||||
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', code, null);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
// PUT /api/admin/users/:id/status
|
||||
export async function handleAdminSetUserStatus(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User,
|
||||
targetUserId: string
|
||||
): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
let body: { status?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
|
||||
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
|
||||
if (!nextStatus) {
|
||||
return errorResponse('status must be active or banned', 400);
|
||||
}
|
||||
if (targetUserId === actorUser.id && nextStatus !== 'active') {
|
||||
return errorResponse('You cannot ban yourself', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const target = await storage.getUserById(targetUserId);
|
||||
if (!target) {
|
||||
return errorResponse('User not found', 404);
|
||||
}
|
||||
|
||||
target.status = nextStatus;
|
||||
target.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(target);
|
||||
if (nextStatus === 'banned') {
|
||||
await storage.deleteRefreshTokensByUserId(target.id);
|
||||
}
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, {
|
||||
status: nextStatus,
|
||||
});
|
||||
|
||||
return jsonResponse({
|
||||
id: target.id,
|
||||
email: target.email,
|
||||
role: target.role,
|
||||
status: target.status,
|
||||
object: 'user',
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE /api/admin/users/:id
|
||||
export async function handleAdminDeleteUser(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User,
|
||||
targetUserId: string
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
if (targetUserId === actorUser.id) {
|
||||
return errorResponse('You cannot delete yourself', 400);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const target = await storage.getUserById(targetUserId);
|
||||
if (!target) {
|
||||
return errorResponse('User not found', 404);
|
||||
}
|
||||
|
||||
await storage.deleteRefreshTokensByUserId(target.id);
|
||||
await storage.deleteUserById(target.id);
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, {
|
||||
email: target.email,
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
Reference in New Issue
Block a user