From a22e8f60dd2292eb469db74d09968a62c604c6ac Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Wed, 17 Jun 2026 19:52:29 +0700 Subject: [PATCH 1/8] feat(server): add admin route skeleton --- apps/server/src/app.ts | 2 + .../server/src/routes/admin/admin.handlers.ts | 13 +++++ apps/server/src/routes/admin/admin.index.ts | 8 +++ apps/server/src/routes/admin/admin.routes.ts | 41 +++++++++++++++ apps/server/src/routes/admin/admin.test.ts | 50 +++++++++++++++++++ 5 files changed, 114 insertions(+) create mode 100644 apps/server/src/routes/admin/admin.handlers.ts create mode 100644 apps/server/src/routes/admin/admin.index.ts create mode 100644 apps/server/src/routes/admin/admin.routes.ts create mode 100644 apps/server/src/routes/admin/admin.test.ts diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 950e8d6..fb6d4aa 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -10,6 +10,7 @@ import { createDrizzleImportSessionsAdapter } from './repositories/import-sessio import { createDrizzleLinksAdapter } from './repositories/links.repository.js'; import { createDrizzleTagsAdapter } from './repositories/tags.repository.js'; +import admin from './routes/admin/admin.index.js'; import auth from './routes/auth/auth.index.js'; import files from './routes/files/files.index.js'; import health from './routes/health/health.index.js'; @@ -38,6 +39,7 @@ configureOpenAPI(app); const router = app .route('/', health) + .route('/', admin) .route('/', auth) .route('/', home) .route('/', links) diff --git a/apps/server/src/routes/admin/admin.handlers.ts b/apps/server/src/routes/admin/admin.handlers.ts new file mode 100644 index 0000000..b2d293c --- /dev/null +++ b/apps/server/src/routes/admin/admin.handlers.ts @@ -0,0 +1,13 @@ +import { successResponse } from '@/lib/response.js'; +import type { AppRouteHandler } from '@/lib/types.js'; + +import { adminService } from '@/services/admin.service.js'; + +import type { ListUsersRoute } from './admin.routes.js'; + +export const listUsers: AppRouteHandler = async (c) => { + const users = await adminService.listUsers(); + const response = successResponse(users, 'Users fetched successfully'); + + return c.json(response, response.status); +}; diff --git a/apps/server/src/routes/admin/admin.index.ts b/apps/server/src/routes/admin/admin.index.ts new file mode 100644 index 0000000..d19c7f1 --- /dev/null +++ b/apps/server/src/routes/admin/admin.index.ts @@ -0,0 +1,8 @@ +import { createRouter } from '@/lib/create-app.js'; + +import * as handlers from './admin.handlers.js'; +import * as routes from './admin.routes.js'; + +const router = createRouter().openapi(routes.listUsers, handlers.listUsers); + +export default router; diff --git a/apps/server/src/routes/admin/admin.routes.ts b/apps/server/src/routes/admin/admin.routes.ts new file mode 100644 index 0000000..2e53e29 --- /dev/null +++ b/apps/server/src/routes/admin/admin.routes.ts @@ -0,0 +1,41 @@ +import { createRoute, z } from '@hono/zod-openapi'; + +import { jsonContent } from '@/lib/openapi.js'; +import { errorResponseSchema, HttpStatus, successResponseSchema } from '@/lib/response.js'; + +import { adminUser } from '@/middlewares/admin-user.js'; +import { currentUser } from '@/middlewares/current-user.js'; + +const tags = ['Admin']; + +const adminSafeUserSchema = z.object({ + id: z.string(), + email: z.email(), + name: z.string().nullable(), + avatar: z.string().nullable(), + role: z.string(), + settings: z.record(z.string(), z.unknown()), + deletedAt: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string() +}); + +export const listUsers = createRoute({ + tags, + method: 'get', + path: '/admin/users', + middleware: [currentUser, adminUser], + responses: { + [HttpStatus.OK]: jsonContent( + successResponseSchema(z.array(adminSafeUserSchema)), + 'Users fetched successfully' + ), + [HttpStatus.UNAUTHORIZED]: jsonContent( + errorResponseSchema(HttpStatus.UNAUTHORIZED), + 'Unauthorized' + ), + [HttpStatus.FORBIDDEN]: jsonContent(errorResponseSchema(HttpStatus.FORBIDDEN), 'Forbidden') + } +}); + +export type ListUsersRoute = typeof listUsers; diff --git a/apps/server/src/routes/admin/admin.test.ts b/apps/server/src/routes/admin/admin.test.ts new file mode 100644 index 0000000..b913e52 --- /dev/null +++ b/apps/server/src/routes/admin/admin.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { createInMemoryRepos } from '@/tests/in-memory/index.js'; + +import { createTestApp } from '@/lib/create-app.js'; +import { generateToken } from '@/lib/jwt.js'; +import { HttpStatus } from '@/lib/response.js'; + +import router from './admin.index.js'; + +function buildApp() { + const repos = createInMemoryRepos(); + const app = createTestApp(router, (app) => { + app.use('*', async (c, next) => { + c.set('repos', repos); + return next(); + }); + }); + + return { app, repos }; +} + +describe('admin routes', () => { + describe('GET /admin/users', () => { + it('returns 401 without auth', async () => { + const { app } = buildApp(); + + const response = await app.request('/admin/users'); + + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); + }); + + it('returns 403 for non-admin users', async () => { + const { app, repos } = buildApp(); + const user = await repos.auth.create({ + email: 'reader@example.com', + passwordHash: 'not-used-in-this-test', + name: 'Reader', + role: 'user' + }); + const token = await generateToken(user.id, user.email); + + const response = await app.request('/admin/users', { + headers: { Cookie: `token=${token}` } + }); + + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + }); +}); From ac557e9111a561ef728091f9e3ea182acfc442c9 Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Wed, 17 Jun 2026 19:53:30 +0700 Subject: [PATCH 2/8] feat(server): add admin account management --- .../src/repositories/auth.repository.ts | 58 +++- .../server/src/routes/admin/admin.handlers.ts | 134 +++++++- apps/server/src/routes/admin/admin.index.ts | 8 +- apps/server/src/routes/admin/admin.routes.ts | 174 ++++++++++- apps/server/src/routes/admin/admin.test.ts | 294 +++++++++++++++++- apps/server/src/routes/files/files.test.ts | 5 +- apps/server/src/services/admin.service.ts | 107 ++++--- apps/server/src/tests/in-memory/auth.ts | 102 +++++- 8 files changed, 823 insertions(+), 59 deletions(-) diff --git a/apps/server/src/repositories/auth.repository.ts b/apps/server/src/repositories/auth.repository.ts index 657ccdf..27474b5 100644 --- a/apps/server/src/repositories/auth.repository.ts +++ b/apps/server/src/repositories/auth.repository.ts @@ -1,4 +1,4 @@ -import { and, count, eq, isNull } from 'drizzle-orm'; +import { and, count, desc, eq, isNotNull, isNull } from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import type * as schema from '@/db/schemas/index.js'; @@ -16,6 +16,14 @@ import type { type DrizzleClient = NodePgDatabase; +export type ListUsersStatus = 'active' | 'deleted' | 'all'; + +export interface ListUsersOptions { + limit?: number; + offset?: number; + status?: ListUsersStatus; +} + const publicUserColumns = { id: usersTable.id, email: usersTable.email, @@ -43,14 +51,26 @@ export interface AuthRepository { // Returns full User including passwordHash — only for auth verification findByIdWithCredentials(id: string): Promise; findByIdIncludingDeleted(id: string): Promise; + listUsers(options?: ListUsersOptions): Promise; update( id: string, updates: Partial> ): Promise; + updateUserForAdmin( + id: string, + updates: Partial> + ): Promise; updateRole(id: string, role: string): Promise; updatePassword(id: string, passwordHash: string): Promise; updateDeletedAt(id: string, deletedAt: string | null): Promise; countUsers(): Promise; + countActiveAdmins(): Promise; +} + +function getListUsersWhere(status: ListUsersStatus) { + if (status === 'active') return isNull(usersTable.deletedAt); + if (status === 'deleted') return isNotNull(usersTable.deletedAt); + return undefined; } export function createDrizzleAuthAdapter(db: DrizzleClient): AuthRepository { @@ -126,6 +146,23 @@ export function createDrizzleAuthAdapter(db: DrizzleClient): AuthRepository { return user; }, + async listUsers(options = {}) { + const { limit = 50, offset = 0, status = 'active' } = options; + + const users = await db + .select(userWithoutPasswordColumns) + .from(usersTable) + .where(getListUsersWhere(status)) + .orderBy(desc(usersTable.createdAt)) + .limit(limit) + .offset(offset); + + return users.map((user) => ({ + ...user, + settings: user.settings as Record + })); + }, + async update(id, updates) { const [updatedUser] = await db .update(usersTable) @@ -141,6 +178,16 @@ export function createDrizzleAuthAdapter(db: DrizzleClient): AuthRepository { return updatedUser; }, + async updateUserForAdmin(id, updates) { + const [updatedUser] = await db + .update(usersTable) + .set(updates) + .where(eq(usersTable.id, id)) + .returning(userWithoutPasswordColumns); + + return updatedUser ?? null; + }, + async updatePassword(id, passwordHash) { const [updatedUser] = await db .update(usersTable) @@ -199,6 +246,15 @@ export function createDrizzleAuthAdapter(db: DrizzleClient): AuthRepository { } return Number(users.count); + }, + + async countActiveAdmins() { + const [users] = await db + .select({ count: count() }) + .from(usersTable) + .where(and(eq(usersTable.role, 'admin'), isNull(usersTable.deletedAt))); + + return Number(users?.count ?? 0); } }; } diff --git a/apps/server/src/routes/admin/admin.handlers.ts b/apps/server/src/routes/admin/admin.handlers.ts index b2d293c..38a1d4b 100644 --- a/apps/server/src/routes/admin/admin.handlers.ts +++ b/apps/server/src/routes/admin/admin.handlers.ts @@ -1,13 +1,139 @@ -import { successResponse } from '@/lib/response.js'; +import { demoModeForbiddenResponse, isDemoMode } from '@/lib/demo-mode.js'; +import { errorResponse, HttpStatus, successResponse } from '@/lib/response.js'; import type { AppRouteHandler } from '@/lib/types.js'; -import { adminService } from '@/services/admin.service.js'; +import { AdminService, AdminServiceError } from '@/services/admin.service.js'; -import type { ListUsersRoute } from './admin.routes.js'; +import type { + GetUserRoute, + ListUsersRoute, + ResetPasswordRoute, + RestoreUserRoute, + SoftDeleteUserRoute, + UpdateUserRoute +} from './admin.routes.js'; export const listUsers: AppRouteHandler = async (c) => { - const users = await adminService.listUsers(); + const service = new AdminService(c.get('repos').auth); + const query = c.req.valid('query'); + const users = await service.listUsers(query); const response = successResponse(users, 'Users fetched successfully'); return c.json(response, response.status); }; + +export const getUser: AppRouteHandler = async (c) => { + const service = new AdminService(c.get('repos').auth); + const { id } = c.req.valid('param'); + const user = await service.getUserIncludingDeleted(id); + + if (!user) { + const response = errorResponse('User not found', HttpStatus.NOT_FOUND); + return c.json(response, response.status); + } + + const response = successResponse(user, 'User fetched successfully'); + return c.json(response, response.status); +}; + +export const updateUser: AppRouteHandler = async (c) => { + if (isDemoMode()) { + const response = demoModeForbiddenResponse(); + return c.json(response, response.status); + } + + const service = new AdminService(c.get('repos').auth); + const { id } = c.req.valid('param'); + const data = c.req.valid('json'); + + try { + const user = await service.updateUser(id, data); + const response = successResponse(user, 'User updated successfully'); + return c.json(response, response.status); + } catch (error) { + if (error instanceof AdminServiceError && error.status === HttpStatus.NOT_FOUND) { + const response = errorResponse(error.message, HttpStatus.NOT_FOUND); + return c.json(response, response.status); + } + + const message = error instanceof Error ? error.message : 'Failed to update user'; + const response = errorResponse(message, HttpStatus.BAD_REQUEST); + return c.json(response, response.status); + } +}; + +export const resetPassword: AppRouteHandler = async (c) => { + if (isDemoMode()) { + const response = demoModeForbiddenResponse(); + return c.json(response, response.status); + } + + const service = new AdminService(c.get('repos').auth); + const { id } = c.req.valid('param'); + const { newPassword, confirmNewPassword } = c.req.valid('json'); + + try { + await service.resetUserPassword(id, newPassword, confirmNewPassword); + const response = successResponse(null, 'Password reset successfully'); + return c.json(response, response.status); + } catch (error) { + if (error instanceof AdminServiceError && error.status === HttpStatus.NOT_FOUND) { + const response = errorResponse(error.message, HttpStatus.NOT_FOUND); + return c.json(response, response.status); + } + + const message = error instanceof Error ? error.message : 'Failed to reset password'; + const response = errorResponse(message, HttpStatus.BAD_REQUEST); + return c.json(response, response.status); + } +}; + +export const softDeleteUser: AppRouteHandler = async (c) => { + if (isDemoMode()) { + const response = demoModeForbiddenResponse(); + return c.json(response, response.status); + } + + const service = new AdminService(c.get('repos').auth); + const { id } = c.req.valid('param'); + + try { + const user = await service.softDeleteUser(id); + const response = successResponse(user, 'User deleted successfully'); + return c.json(response, response.status); + } catch (error) { + if (error instanceof AdminServiceError && error.status === HttpStatus.NOT_FOUND) { + const response = errorResponse(error.message, HttpStatus.NOT_FOUND); + return c.json(response, response.status); + } + + const message = error instanceof Error ? error.message : 'Failed to delete user'; + const response = errorResponse(message, HttpStatus.BAD_REQUEST); + return c.json(response, response.status); + } +}; + +export const restoreUser: AppRouteHandler = async (c) => { + if (isDemoMode()) { + const response = demoModeForbiddenResponse(); + return c.json(response, response.status); + } + + const service = new AdminService(c.get('repos').auth); + const { id } = c.req.valid('param'); + + try { + const user = await service.restoreUser(id); + const response = successResponse(user, 'User restored successfully'); + return c.json(response, response.status); + } catch (error) { + if (error instanceof AdminServiceError && error.status === HttpStatus.NOT_FOUND) { + const response = errorResponse(error.message, HttpStatus.NOT_FOUND); + return c.json(response, response.status); + } + + const message = error instanceof Error ? error.message : 'Failed to restore user'; + const response = errorResponse(message, HttpStatus.BAD_REQUEST); + return c.json(response, response.status); + } +}; diff --git a/apps/server/src/routes/admin/admin.index.ts b/apps/server/src/routes/admin/admin.index.ts index d19c7f1..baed855 100644 --- a/apps/server/src/routes/admin/admin.index.ts +++ b/apps/server/src/routes/admin/admin.index.ts @@ -3,6 +3,12 @@ import { createRouter } from '@/lib/create-app.js'; import * as handlers from './admin.handlers.js'; import * as routes from './admin.routes.js'; -const router = createRouter().openapi(routes.listUsers, handlers.listUsers); +const router = createRouter() + .openapi(routes.listUsers, handlers.listUsers) + .openapi(routes.getUser, handlers.getUser) + .openapi(routes.updateUser, handlers.updateUser) + .openapi(routes.resetPassword, handlers.resetPassword) + .openapi(routes.softDeleteUser, handlers.softDeleteUser) + .openapi(routes.restoreUser, handlers.restoreUser); export default router; diff --git a/apps/server/src/routes/admin/admin.routes.ts b/apps/server/src/routes/admin/admin.routes.ts index 2e53e29..2784654 100644 --- a/apps/server/src/routes/admin/admin.routes.ts +++ b/apps/server/src/routes/admin/admin.routes.ts @@ -9,7 +9,7 @@ import { currentUser } from '@/middlewares/current-user.js'; const tags = ['Admin']; const adminSafeUserSchema = z.object({ - id: z.string(), + id: z.uuid(), email: z.email(), name: z.string().nullable(), avatar: z.string().nullable(), @@ -20,11 +20,36 @@ const adminSafeUserSchema = z.object({ updatedAt: z.string() }); +const userParamsSchema = z.object({ id: z.uuid() }); +const listUsersQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), + status: z.enum(['active', 'deleted', 'all']).default('active') +}); + +const updateUserSchema = z.object({ + name: z.string().min(1).max(255).optional(), + role: z.enum(['admin', 'user']).optional() +}); + +const resetPasswordSchema = z + .object({ + newPassword: z.string().min(8), + confirmNewPassword: z.string() + }) + .refine((data) => data.newPassword === data.confirmNewPassword, { + message: 'Passwords do not match', + path: ['confirmNewPassword'] + }); + export const listUsers = createRoute({ tags, method: 'get', path: '/admin/users', middleware: [currentUser, adminUser], + request: { + query: listUsersQuerySchema + }, responses: { [HttpStatus.OK]: jsonContent( successResponseSchema(z.array(adminSafeUserSchema)), @@ -38,4 +63,151 @@ export const listUsers = createRoute({ } }); +export const getUser = createRoute({ + tags, + method: 'get', + path: '/admin/users/{id}', + middleware: [currentUser, adminUser], + request: { params: userParamsSchema }, + responses: { + [HttpStatus.OK]: jsonContent( + successResponseSchema(adminSafeUserSchema), + 'User fetched successfully' + ), + [HttpStatus.NOT_FOUND]: jsonContent( + errorResponseSchema(HttpStatus.NOT_FOUND), + 'User not found' + ), + [HttpStatus.UNAUTHORIZED]: jsonContent( + errorResponseSchema(HttpStatus.UNAUTHORIZED), + 'Unauthorized' + ), + [HttpStatus.FORBIDDEN]: jsonContent(errorResponseSchema(HttpStatus.FORBIDDEN), 'Forbidden') + } +}); + +export const updateUser = createRoute({ + tags, + method: 'patch', + path: '/admin/users/{id}', + middleware: [currentUser, adminUser], + request: { + params: userParamsSchema, + body: jsonContent(updateUserSchema, 'Update user') + }, + responses: { + [HttpStatus.OK]: jsonContent( + successResponseSchema(adminSafeUserSchema), + 'User updated successfully' + ), + [HttpStatus.BAD_REQUEST]: jsonContent( + errorResponseSchema(HttpStatus.BAD_REQUEST), + 'User update failed' + ), + [HttpStatus.NOT_FOUND]: jsonContent( + errorResponseSchema(HttpStatus.NOT_FOUND), + 'User not found' + ), + [HttpStatus.UNAUTHORIZED]: jsonContent( + errorResponseSchema(HttpStatus.UNAUTHORIZED), + 'Unauthorized' + ), + [HttpStatus.FORBIDDEN]: jsonContent(errorResponseSchema(HttpStatus.FORBIDDEN), 'Forbidden'), + [HttpStatus.UNPROCESSABLE_ENTITY]: jsonContent( + errorResponseSchema(HttpStatus.UNPROCESSABLE_ENTITY), + 'Validation failed' + ) + } +}); + +export const resetPassword = createRoute({ + tags, + method: 'post', + path: '/admin/users/{id}/reset-password', + middleware: [currentUser, adminUser], + request: { + params: userParamsSchema, + body: jsonContent(resetPasswordSchema, 'Reset user password') + }, + responses: { + [HttpStatus.OK]: jsonContent(successResponseSchema(z.null()), 'Password reset successfully'), + [HttpStatus.BAD_REQUEST]: jsonContent( + errorResponseSchema(HttpStatus.BAD_REQUEST), + 'Password reset failed' + ), + [HttpStatus.NOT_FOUND]: jsonContent( + errorResponseSchema(HttpStatus.NOT_FOUND), + 'User not found' + ), + [HttpStatus.UNAUTHORIZED]: jsonContent( + errorResponseSchema(HttpStatus.UNAUTHORIZED), + 'Unauthorized' + ), + [HttpStatus.FORBIDDEN]: jsonContent(errorResponseSchema(HttpStatus.FORBIDDEN), 'Forbidden'), + [HttpStatus.UNPROCESSABLE_ENTITY]: jsonContent( + errorResponseSchema(HttpStatus.UNPROCESSABLE_ENTITY), + 'Validation failed' + ) + } +}); + +export const softDeleteUser = createRoute({ + tags, + method: 'delete', + path: '/admin/users/{id}', + middleware: [currentUser, adminUser], + request: { params: userParamsSchema }, + responses: { + [HttpStatus.OK]: jsonContent( + successResponseSchema(adminSafeUserSchema), + 'User deleted successfully' + ), + [HttpStatus.BAD_REQUEST]: jsonContent( + errorResponseSchema(HttpStatus.BAD_REQUEST), + 'User delete failed' + ), + [HttpStatus.NOT_FOUND]: jsonContent( + errorResponseSchema(HttpStatus.NOT_FOUND), + 'User not found' + ), + [HttpStatus.UNAUTHORIZED]: jsonContent( + errorResponseSchema(HttpStatus.UNAUTHORIZED), + 'Unauthorized' + ), + [HttpStatus.FORBIDDEN]: jsonContent(errorResponseSchema(HttpStatus.FORBIDDEN), 'Forbidden') + } +}); + +export const restoreUser = createRoute({ + tags, + method: 'post', + path: '/admin/users/{id}/restore', + middleware: [currentUser, adminUser], + request: { params: userParamsSchema }, + responses: { + [HttpStatus.OK]: jsonContent( + successResponseSchema(adminSafeUserSchema), + 'User restored successfully' + ), + [HttpStatus.BAD_REQUEST]: jsonContent( + errorResponseSchema(HttpStatus.BAD_REQUEST), + 'User restore failed' + ), + [HttpStatus.NOT_FOUND]: jsonContent( + errorResponseSchema(HttpStatus.NOT_FOUND), + 'User not found' + ), + [HttpStatus.UNAUTHORIZED]: jsonContent( + errorResponseSchema(HttpStatus.UNAUTHORIZED), + 'Unauthorized' + ), + [HttpStatus.FORBIDDEN]: jsonContent(errorResponseSchema(HttpStatus.FORBIDDEN), 'Forbidden') + } +}); + export type ListUsersRoute = typeof listUsers; +export type GetUserRoute = typeof getUser; +export type UpdateUserRoute = typeof updateUser; +export type ResetPasswordRoute = typeof resetPassword; +export type SoftDeleteUserRoute = typeof softDeleteUser; +export type RestoreUserRoute = typeof restoreUser; diff --git a/apps/server/src/routes/admin/admin.test.ts b/apps/server/src/routes/admin/admin.test.ts index b913e52..d39dec5 100644 --- a/apps/server/src/routes/admin/admin.test.ts +++ b/apps/server/src/routes/admin/admin.test.ts @@ -4,14 +4,15 @@ import { createInMemoryRepos } from '@/tests/in-memory/index.js'; import { createTestApp } from '@/lib/create-app.js'; import { generateToken } from '@/lib/jwt.js'; +import { passwordManager } from '@/lib/password-manager.js'; import { HttpStatus } from '@/lib/response.js'; import router from './admin.index.js'; function buildApp() { const repos = createInMemoryRepos(); - const app = createTestApp(router, (app) => { - app.use('*', async (c, next) => { + const app = createTestApp(router, (testApp) => { + testApp.use('*', async (c, next) => { c.set('repos', repos); return next(); }); @@ -20,6 +21,18 @@ function buildApp() { return { app, repos }; } +async function createAdmin(repos: ReturnType) { + const admin = await repos.auth.create({ + email: 'admin@example.com', + passwordHash: 'not-used-in-this-test', + name: 'Admin', + role: 'admin' + }); + const token = await generateToken(admin.id, admin.email); + + return { admin, token }; +} + describe('admin routes', () => { describe('GET /admin/users', () => { it('returns 401 without auth', async () => { @@ -46,5 +59,282 @@ describe('admin routes', () => { expect(response.status).toBe(HttpStatus.FORBIDDEN); }); + + it('returns active users without password hashes', async () => { + const { app, repos } = buildApp(); + const { token } = await createAdmin(repos); + await repos.auth.create({ + email: 'active@example.com', + passwordHash: 'active-secret', + name: 'Active User', + role: 'user' + }); + const deleted = await repos.auth.create({ + email: 'deleted@example.com', + passwordHash: 'deleted-secret', + name: 'Deleted User', + role: 'user' + }); + await repos.auth.updateDeletedAt(deleted.id, new Date().toISOString()); + + const response = await app.request('/admin/users', { + headers: { Cookie: `token=${token}` } + }); + const body = await response.json(); + + expect(response.status).toBe(HttpStatus.OK); + expect(body.result).toHaveLength(2); + expect(body.result.map((user: { email: string }) => user.email)).toEqual([ + 'active@example.com', + 'admin@example.com' + ]); + expect(body.result[0]).not.toHaveProperty('passwordHash'); + }); + + it('filters deleted users when requested', async () => { + const { app, repos } = buildApp(); + const { token } = await createAdmin(repos); + await repos.auth.create({ + email: 'visible@example.com', + passwordHash: 'secret', + name: 'Visible User', + role: 'user' + }); + const deleted = await repos.auth.create({ + email: 'only-deleted@example.com', + passwordHash: 'secret', + name: 'Only Deleted', + role: 'user' + }); + await repos.auth.updateDeletedAt(deleted.id, new Date().toISOString()); + + const response = await app.request('/admin/users?status=deleted', { + headers: { Cookie: `token=${token}` } + }); + const body = await response.json(); + + expect(response.status).toBe(HttpStatus.OK); + expect(body.result).toHaveLength(1); + expect(body.result[0]).toMatchObject({ email: 'only-deleted@example.com' }); + }); + }); + + describe('GET /admin/users/:id', () => { + it('returns 404 for missing users', async () => { + const { app, repos } = buildApp(); + const { token } = await createAdmin(repos); + + const response = await app.request(`/admin/users/${crypto.randomUUID()}`, { + headers: { Cookie: `token=${token}` } + }); + + expect(response.status).toBe(HttpStatus.NOT_FOUND); + }); + + it('returns deleted user details without password hashes', async () => { + const { app, repos } = buildApp(); + const { token } = await createAdmin(repos); + const user = await repos.auth.create({ + email: 'deleted-detail@example.com', + passwordHash: 'secret', + name: 'Deleted Detail', + role: 'user' + }); + await repos.auth.updateDeletedAt(user.id, new Date().toISOString()); + + const response = await app.request(`/admin/users/${user.id}`, { + headers: { Cookie: `token=${token}` } + }); + const body = await response.json(); + + expect(response.status).toBe(HttpStatus.OK); + expect(body.result.email).toBe('deleted-detail@example.com'); + expect(body.result.deletedAt).toEqual(expect.any(String)); + expect(body.result).not.toHaveProperty('passwordHash'); + }); + }); + + describe('PATCH /admin/users/:id', () => { + it('updates allowed account fields', async () => { + const { app, repos } = buildApp(); + const { token } = await createAdmin(repos); + const user = await repos.auth.create({ + email: 'rename@example.com', + passwordHash: 'secret', + name: 'Before', + role: 'user' + }); + + const response = await app.request(`/admin/users/${user.id}`, { + method: 'PATCH', + headers: { Cookie: `token=${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'After', role: 'admin' }) + }); + const body = await response.json(); + + expect(response.status).toBe(HttpStatus.OK); + expect(body.result).toMatchObject({ + email: 'rename@example.com', + name: 'After', + role: 'admin' + }); + expect(body.result).not.toHaveProperty('passwordHash'); + }); + + it('prevents demoting the last active admin', async () => { + const { app, repos } = buildApp(); + const { admin, token } = await createAdmin(repos); + + const response = await app.request(`/admin/users/${admin.id}`, { + method: 'PATCH', + headers: { Cookie: `token=${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: 'user' }) + }); + + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + + it('rejects empty updates', async () => { + const { app, repos } = buildApp(); + const { token } = await createAdmin(repos); + const user = await repos.auth.create({ + email: 'empty-update@example.com', + passwordHash: 'secret', + name: 'Empty Update', + role: 'user' + }); + + const response = await app.request(`/admin/users/${user.id}`, { + method: 'PATCH', + headers: { Cookie: `token=${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + + it('rejects invalid roles', async () => { + const { app, repos } = buildApp(); + const { token } = await createAdmin(repos); + const user = await repos.auth.create({ + email: 'invalid-role@example.com', + passwordHash: 'secret', + name: 'Invalid Role', + role: 'user' + }); + + const response = await app.request(`/admin/users/${user.id}`, { + method: 'PATCH', + headers: { Cookie: `token=${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: 'owner' }) + }); + + expect(response.status).toBe(HttpStatus.UNPROCESSABLE_ENTITY); + }); + }); + + describe('POST /admin/users/:id/reset-password', () => { + it('updates a user password without returning it', async () => { + const { app, repos } = buildApp(); + const { token } = await createAdmin(repos); + const user = await repos.auth.create({ + email: 'reset@example.com', + passwordHash: await passwordManager.hash('old-password'), + name: 'Reset User', + role: 'user' + }); + + const response = await app.request(`/admin/users/${user.id}/reset-password`, { + method: 'POST', + headers: { Cookie: `token=${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ newPassword: 'new-password', confirmNewPassword: 'new-password' }) + }); + const credentials = await repos.auth.findByIdWithCredentials(user.id); + const body = await response.json(); + + expect(response.status).toBe(HttpStatus.OK); + expect(body.result).toBeNull(); + expect(await passwordManager.compare(credentials?.passwordHash ?? '', 'new-password')).toBe( + true + ); + }); + + it('rejects mismatched passwords', async () => { + const { app, repos } = buildApp(); + const { token } = await createAdmin(repos); + const user = await repos.auth.create({ + email: 'mismatch@example.com', + passwordHash: 'secret', + name: 'Mismatch User', + role: 'user' + }); + + const response = await app.request(`/admin/users/${user.id}/reset-password`, { + method: 'POST', + headers: { Cookie: `token=${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ newPassword: 'new-password', confirmNewPassword: 'different' }) + }); + + expect(response.status).toBe(HttpStatus.UNPROCESSABLE_ENTITY); + }); + }); + + describe('DELETE /admin/users/:id', () => { + it('soft-deletes a user', async () => { + const { app, repos } = buildApp(); + const { token } = await createAdmin(repos); + const user = await repos.auth.create({ + email: 'delete@example.com', + passwordHash: 'secret', + name: 'Delete User', + role: 'user' + }); + + const response = await app.request(`/admin/users/${user.id}`, { + method: 'DELETE', + headers: { Cookie: `token=${token}` } + }); + const body = await response.json(); + + expect(response.status).toBe(HttpStatus.OK); + expect(body.result.deletedAt).toEqual(expect.any(String)); + expect(await repos.auth.findById(user.id)).toBeNull(); + }); + + it('prevents deleting the last active admin', async () => { + const { app, repos } = buildApp(); + const { admin, token } = await createAdmin(repos); + + const response = await app.request(`/admin/users/${admin.id}`, { + method: 'DELETE', + headers: { Cookie: `token=${token}` } + }); + + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + }); + }); + + describe('POST /admin/users/:id/restore', () => { + it('restores a soft-deleted user', async () => { + const { app, repos } = buildApp(); + const { token } = await createAdmin(repos); + const user = await repos.auth.create({ + email: 'restore@example.com', + passwordHash: 'secret', + name: 'Restore User', + role: 'user' + }); + await repos.auth.updateDeletedAt(user.id, new Date().toISOString()); + + const response = await app.request(`/admin/users/${user.id}/restore`, { + method: 'POST', + headers: { Cookie: `token=${token}` } + }); + const body = await response.json(); + + expect(response.status).toBe(HttpStatus.OK); + expect(body.result.deletedAt).toBeNull(); + expect(await repos.auth.findById(user.id)).not.toBeNull(); + }); }); }); diff --git a/apps/server/src/routes/files/files.test.ts b/apps/server/src/routes/files/files.test.ts index a1b0268..291fd01 100644 --- a/apps/server/src/routes/files/files.test.ts +++ b/apps/server/src/routes/files/files.test.ts @@ -85,11 +85,14 @@ function createFakeAuthRepository(user: UserWithoutPassword): AuthRepository { findById: async (id) => (id === user.id ? user : null), findByIdWithCredentials: async () => null, findByIdIncludingDeleted: async () => null, + listUsers: async () => [], update: async () => null, + updateUserForAdmin: async () => null, updateRole: async () => ({ ...user, role: 'user' }), updatePassword: async () => null, updateDeletedAt: async () => user, - countUsers: async () => 1 + countUsers: async () => 1, + countActiveAdmins: async () => 0 } satisfies AuthRepository; } diff --git a/apps/server/src/services/admin.service.ts b/apps/server/src/services/admin.service.ts index c4bb96b..ed1a244 100644 --- a/apps/server/src/services/admin.service.ts +++ b/apps/server/src/services/admin.service.ts @@ -1,53 +1,32 @@ -import { desc, isNull } from 'drizzle-orm'; - import { db } from '@/db/index.js'; -import { usersTable } from '@/db/schemas/index.js'; import { passwordManager } from '@/lib/password-manager.js'; +import { HttpStatus, type HttpStatusCode } from '@/lib/response.js'; -import type { AuthRepository } from '@/repositories/auth.repository.js'; +import type { AuthRepository, ListUsersOptions } from '@/repositories/auth.repository.js'; import { createDrizzleAuthAdapter } from '@/repositories/auth.repository.js'; import type { UserWithoutPassword } from '@/types/auth.js'; -interface ListUsersOptions { - limit?: number; - offset?: number; -} - interface UpdateUserData { name?: string; - role?: string; + role?: 'admin' | 'user'; +} + +export class AdminServiceError extends Error { + constructor( + message: string, + public readonly status: HttpStatusCode + ) { + super(message); + } } -class AdminService { +export class AdminService { constructor(private repo: AuthRepository = createDrizzleAuthAdapter(db)) {} async listUsers(options: ListUsersOptions = {}): Promise { - const { limit = 50, offset = 0 } = options; - - const users = await db - .select({ - id: usersTable.id, - email: usersTable.email, - name: usersTable.name, - role: usersTable.role, - settings: usersTable.settings, - deletedAt: usersTable.deletedAt, - createdAt: usersTable.createdAt, - updatedAt: usersTable.updatedAt, - avatar: usersTable.avatar - }) - .from(usersTable) - .where(isNull(usersTable.deletedAt)) - .orderBy(desc(usersTable.createdAt)) - .limit(limit) - .offset(offset); - - return users.map((user) => ({ - ...user, - settings: user.settings as Record - })); + return await this.repo.listUsers(options); } async getUser(userId: string): Promise { @@ -58,18 +37,44 @@ class AdminService { return await this.repo.findByIdIncludingDeleted(userId); } - async updateUser(userId: string, data: UpdateUserData) { - const updates: { name?: string; role?: string } = {}; + async updateUser(userId: string, data: UpdateUserData): Promise { + const existing = await this.repo.findByIdIncludingDeleted(userId); + + if (!existing) { + throw new AdminServiceError('User not found', HttpStatus.NOT_FOUND); + } + + const updates: { name?: string; role?: 'admin' | 'user' } = {}; if (data.name !== undefined) updates.name = data.name; if (data.role !== undefined) updates.role = data.role; - return await this.repo.update(userId, updates); + if (Object.keys(updates).length === 0) { + throw new AdminServiceError('No fields to update', HttpStatus.BAD_REQUEST); + } + + if (existing.role === 'admin' && updates.role === 'user' && existing.deletedAt === null) { + await this.ensureAnotherActiveAdmin(); + } + + const updated = await this.repo.updateUserForAdmin(userId, updates); + + if (!updated) { + throw new AdminServiceError('User not found', HttpStatus.NOT_FOUND); + } + + return updated; } async resetUserPassword(userId: string, newPassword: string, confirmNewPassword: string) { + const existing = await this.repo.findByIdIncludingDeleted(userId); + + if (!existing) { + throw new AdminServiceError('User not found', HttpStatus.NOT_FOUND); + } + if (newPassword !== confirmNewPassword) { - throw new Error('Passwords do not match'); + throw new AdminServiceError('Passwords do not match', HttpStatus.BAD_REQUEST); } const passwordHash = await passwordManager.hash(newPassword); @@ -77,12 +82,36 @@ class AdminService { } async softDeleteUser(userId: string) { + const existing = await this.repo.findByIdIncludingDeleted(userId); + + if (!existing) { + throw new AdminServiceError('User not found', HttpStatus.NOT_FOUND); + } + + if (existing.role === 'admin' && existing.deletedAt === null) { + await this.ensureAnotherActiveAdmin(); + } + return await this.repo.updateDeletedAt(userId, new Date().toISOString()); } async restoreUser(userId: string) { + const existing = await this.repo.findByIdIncludingDeleted(userId); + + if (!existing) { + throw new AdminServiceError('User not found', HttpStatus.NOT_FOUND); + } + return await this.repo.updateDeletedAt(userId, null); } + + private async ensureAnotherActiveAdmin() { + const activeAdminCount = await this.repo.countActiveAdmins(); + + if (activeAdminCount <= 1) { + throw new AdminServiceError('Cannot remove the last active admin', HttpStatus.BAD_REQUEST); + } + } } export const adminService = new AdminService(); diff --git a/apps/server/src/tests/in-memory/auth.ts b/apps/server/src/tests/in-memory/auth.ts index 7365ebb..5b59c21 100644 --- a/apps/server/src/tests/in-memory/auth.ts +++ b/apps/server/src/tests/in-memory/auth.ts @@ -5,26 +5,45 @@ import type { User } from '@/types/auth.js'; export function createInMemoryAuthAdapter(): AuthRepository { const usersByEmail = new Map(); const usersById = new Map(); + let createdOffset = 0; + + function withoutPassword(user: User) { + const { passwordHash: _passwordHash, ...rest } = user; + return rest; + } return { findByEmail: async (email) => usersByEmail.get(email) ?? null, findById: async (id) => { const user = usersById.get(id); - if (!user) return null; - const { passwordHash: _passwordHash, ...rest } = user; - return rest; + if (!user || user.deletedAt) return null; + return withoutPassword(user); }, findByIdWithCredentials: async (id) => usersById.get(id) ?? null, findByIdIncludingDeleted: async (id) => { const user = usersById.get(id); if (!user) return null; - const { passwordHash: _passwordHash, ...rest } = user; - return rest; + return withoutPassword(user); + }, + + listUsers: async ({ limit = 50, offset = 0, status = 'active' } = {}) => { + const users = Array.from(usersById.values()) + .filter((user) => { + if (status === 'active') return user.deletedAt === null; + if (status === 'deleted') return user.deletedAt !== null; + return true; + }) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .slice(offset, offset + limit) + .map(withoutPassword); + + return users; }, create: async (data) => { + const now = new Date(Date.now() + createdOffset++).toISOString(); const user: User = { ...data, passwordHash: data.passwordHash, @@ -34,8 +53,8 @@ export function createInMemoryAuthAdapter(): AuthRepository { avatar: data.avatar ?? null, settings: {}, deletedAt: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() + createdAt: now, + updatedAt: now }; usersByEmail.set(user.email, user); usersById.set(user.id, user); @@ -52,9 +71,72 @@ export function createInMemoryAuthAdapter(): AuthRepository { countUsers: async () => usersByEmail.size, - update: async () => null, + countActiveAdmins: async () => + Array.from(usersById.values()).filter((user) => user.role === 'admin' && !user.deletedAt) + .length, + + update: async (id, updates) => { + const user = usersById.get(id); + if (!user) return null; + + const updated: User = { + ...user, + ...updates, + settings: updates.settings ?? user.settings, + updatedAt: new Date().toISOString() + }; + usersById.set(id, updated); + usersByEmail.delete(user.email); + usersByEmail.set(updated.email, updated); + + const { + passwordHash: _, + role: __, + deletedAt: ___, + createdAt: ____, + updatedAt: _____, + ...publicUser + } = updated; + return publicUser; + }, + updateUserForAdmin: async (id, updates) => { + const user = usersById.get(id); + if (!user) return null; + + const updated = { ...user, ...updates, updatedAt: new Date().toISOString() }; + usersById.set(id, updated); + usersByEmail.set(updated.email, updated); + + return withoutPassword(updated); + }, updateRole: async () => null as never, - updatePassword: async () => null, - updateDeletedAt: async () => null as never + updatePassword: async (id, passwordHash) => { + const user = usersById.get(id); + if (!user) return null; + + const updated = { ...user, passwordHash, updatedAt: new Date().toISOString() }; + usersById.set(id, updated); + usersByEmail.set(updated.email, updated); + + const { + passwordHash: _, + role: __, + deletedAt: ___, + createdAt: ____, + updatedAt: _____, + ...publicUser + } = updated; + return publicUser; + }, + updateDeletedAt: async (id, deletedAt) => { + const user = usersById.get(id); + if (!user) throw new Error(`No user found by id: ${id}`); + + const updated = { ...user, deletedAt, updatedAt: new Date().toISOString() }; + usersById.set(id, updated); + usersByEmail.set(updated.email, updated); + + return withoutPassword(updated); + } } satisfies AuthRepository; } From d8eb83ba06072efe924a8ce5fb4f32e7ea082651 Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Thu, 18 Jun 2026 06:12:17 +0700 Subject: [PATCH 3/8] feat: expose user role and add admin API layer - Server /auth/user now returns role for client navigation affordance - Web auth types map role through get-user, login, register - Add admin web API modules: list, detail, update, reset password, delete, restore - Add admin query keys with mutation invalidation metadata - Server admin authorization remains source of truth --- apps/server/src/routes/auth/auth.handlers.ts | 1 + apps/server/src/routes/auth/auth.routes.ts | 4 +- apps/server/src/routes/auth/auth.test.ts | 1 + .../web/src/features/admin/api/delete-user.ts | 33 ++++++++++++ apps/web/src/features/admin/api/get-user.ts | 32 ++++++++++++ apps/web/src/features/admin/api/get-users.ts | 49 ++++++++++++++++++ apps/web/src/features/admin/api/query-keys.ts | 11 ++++ .../features/admin/api/reset-user-password.ts | 46 +++++++++++++++++ .../src/features/admin/api/restore-user.ts | 33 ++++++++++++ apps/web/src/features/admin/api/types.ts | 20 ++++++++ .../web/src/features/admin/api/update-user.ts | 51 +++++++++++++++++++ .../src/features/auth/api/get-user.test.ts | 18 ++++--- apps/web/src/features/auth/api/get-user.ts | 3 ++ apps/web/src/features/auth/api/login.test.ts | 6 ++- .../src/features/auth/api/register.test.ts | 6 ++- 15 files changed, 303 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/features/admin/api/delete-user.ts create mode 100644 apps/web/src/features/admin/api/get-user.ts create mode 100644 apps/web/src/features/admin/api/get-users.ts create mode 100644 apps/web/src/features/admin/api/query-keys.ts create mode 100644 apps/web/src/features/admin/api/reset-user-password.ts create mode 100644 apps/web/src/features/admin/api/restore-user.ts create mode 100644 apps/web/src/features/admin/api/types.ts create mode 100644 apps/web/src/features/admin/api/update-user.ts diff --git a/apps/server/src/routes/auth/auth.handlers.ts b/apps/server/src/routes/auth/auth.handlers.ts index 669efe5..abc431c 100644 --- a/apps/server/src/routes/auth/auth.handlers.ts +++ b/apps/server/src/routes/auth/auth.handlers.ts @@ -153,6 +153,7 @@ export const getUser: AppRouteHandler = async (c) => { email: user.email, name: user.name, avatar: user.avatar, + role: user.role, settings }, 'User fetched successfully' diff --git a/apps/server/src/routes/auth/auth.routes.ts b/apps/server/src/routes/auth/auth.routes.ts index f94a85f..8489962 100644 --- a/apps/server/src/routes/auth/auth.routes.ts +++ b/apps/server/src/routes/auth/auth.routes.ts @@ -11,6 +11,8 @@ import { authLoginRateLimit, authRegisterRateLimit } from '@/middlewares/rate-li const tags = ['Auth']; +const currentUserSchema = selectUsersSchema.extend({ role: z.string() }); + export const create = createRoute({ tags, method: 'post', @@ -84,7 +86,7 @@ export const getUser = createRoute({ middleware: [currentUser], responses: { [HttpStatus.OK]: jsonContent( - successResponseSchema(selectUsersSchema), + successResponseSchema(currentUserSchema), 'User retrieved successfully' ), [HttpStatus.UNAUTHORIZED]: jsonContent( diff --git a/apps/server/src/routes/auth/auth.test.ts b/apps/server/src/routes/auth/auth.test.ts index 56f16d7..e9f012f 100644 --- a/apps/server/src/routes/auth/auth.test.ts +++ b/apps/server/src/routes/auth/auth.test.ts @@ -224,6 +224,7 @@ describe('auth routes', () => { if (response.status === HttpStatus.OK) { const json = await response.json(); expect(json.result.email).toBe(TEST_EMAIL); + expect(json.result.role).toMatch(/^(admin|user)$/); } }); }); diff --git a/apps/web/src/features/admin/api/delete-user.ts b/apps/web/src/features/admin/api/delete-user.ts new file mode 100644 index 0000000..5d47069 --- /dev/null +++ b/apps/web/src/features/admin/api/delete-user.ts @@ -0,0 +1,33 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { apiClient } from '@/lib/api-client'; +import type { MutationConfig } from '@/lib/react-query'; + +import type { AdminUserResponse } from './types'; + +import { adminKeys, adminUserMutationMeta } from './query-keys'; + +export const deleteAdminUser = async ({ id }: { id: string }): Promise => { + const response = await apiClient.delete(`admin/users/${id}`); + + return response.json(); +}; + +type UseDeleteAdminUserOptions = { + mutationConfig?: MutationConfig; +}; + +export const useDeleteAdminUser = ({ mutationConfig }: UseDeleteAdminUserOptions = {}) => { + const queryClient = useQueryClient(); + const { onSuccess, ...restConfig } = mutationConfig ?? {}; + + return useMutation({ + ...restConfig, + mutationFn: deleteAdminUser, + meta: adminUserMutationMeta, + onSuccess: (data, variables, ...args) => { + queryClient.invalidateQueries({ queryKey: adminKeys.userDetail(variables.id) }); + onSuccess?.(data, variables, ...args); + } + }); +}; diff --git a/apps/web/src/features/admin/api/get-user.ts b/apps/web/src/features/admin/api/get-user.ts new file mode 100644 index 0000000..167288c --- /dev/null +++ b/apps/web/src/features/admin/api/get-user.ts @@ -0,0 +1,32 @@ +import { queryOptions, useQuery } from '@tanstack/react-query'; + +import { apiClient } from '@/lib/api-client'; +import type { QueryConfig } from '@/lib/react-query'; + +import type { AdminUserResponse } from './types'; + +import { adminKeys } from './query-keys'; + +export const getAdminUser = async (id: string): Promise => { + const response = await apiClient.get(`admin/users/${id}`); + + return response.json(); +}; + +export const getAdminUserQueryOptions = (id: string) => + queryOptions({ + enabled: id.length > 0, + queryFn: () => getAdminUser(id), + queryKey: adminKeys.userDetail(id) + }); + +type UseGetAdminUserOptions = { + id: string; + queryConfig?: QueryConfig; +}; + +export const useGetAdminUser = ({ id, queryConfig }: UseGetAdminUserOptions) => + useQuery({ + ...getAdminUserQueryOptions(id), + ...queryConfig + }); diff --git a/apps/web/src/features/admin/api/get-users.ts b/apps/web/src/features/admin/api/get-users.ts new file mode 100644 index 0000000..d79f022 --- /dev/null +++ b/apps/web/src/features/admin/api/get-users.ts @@ -0,0 +1,49 @@ +import { queryOptions, useQuery } from '@tanstack/react-query'; + +import { apiClient } from '@/lib/api-client'; +import type { QueryConfig } from '@/lib/react-query'; + +import type { AdminUserStatus, AdminUsersResponse } from './types'; + +import { adminKeys } from './query-keys'; + +export type GetAdminUsersParams = { + limit?: number; + offset?: number; + status?: AdminUserStatus; +}; + +const toSearchParams = (params: GetAdminUsersParams): Record => { + const searchParams: Record = {}; + + if (params.limit !== undefined) searchParams.limit = String(params.limit); + if (params.offset !== undefined) searchParams.offset = String(params.offset); + if (params.status !== undefined) searchParams.status = params.status; + + return searchParams; +}; + +export const getAdminUsers = async ( + params: GetAdminUsersParams = {} +): Promise => { + const response = await apiClient.get('admin/users', {}, toSearchParams(params)); + + return response.json(); +}; + +export const getAdminUsersQueryOptions = (params: GetAdminUsersParams = {}) => + queryOptions({ + queryFn: () => getAdminUsers(params), + queryKey: adminKeys.userList(params) + }); + +type UseGetAdminUsersOptions = { + params?: GetAdminUsersParams; + queryConfig?: QueryConfig; +}; + +export const useGetAdminUsers = ({ params = {}, queryConfig }: UseGetAdminUsersOptions = {}) => + useQuery({ + ...getAdminUsersQueryOptions(params), + ...queryConfig + }); diff --git a/apps/web/src/features/admin/api/query-keys.ts b/apps/web/src/features/admin/api/query-keys.ts new file mode 100644 index 0000000..793e6bf --- /dev/null +++ b/apps/web/src/features/admin/api/query-keys.ts @@ -0,0 +1,11 @@ +export const adminKeys = { + all: ['admin'] as const, + users: () => [...adminKeys.all, 'users'] as const, + userList: (filters?: Record) => [...adminKeys.users(), { filters }] as const, + userDetails: () => [...adminKeys.users(), 'detail'] as const, + userDetail: (id: string) => [...adminKeys.userDetails(), id] as const +}; + +export const adminUserMutationMeta = { + invalidates: [adminKeys.users()] +}; diff --git a/apps/web/src/features/admin/api/reset-user-password.ts b/apps/web/src/features/admin/api/reset-user-password.ts new file mode 100644 index 0000000..dae7162 --- /dev/null +++ b/apps/web/src/features/admin/api/reset-user-password.ts @@ -0,0 +1,46 @@ +import { useMutation } from '@tanstack/react-query'; +import { z } from 'zod'; + +import { apiClient } from '@/lib/api-client'; +import type { MutationConfig } from '@/lib/react-query'; + +import type { AdminEmptyResponse } from './types'; + +import { adminUserMutationMeta } from './query-keys'; + +export const resetAdminUserPasswordBodySchema = z + .object({ + confirmNewPassword: z.string().min(8), + newPassword: z.string().min(8) + }) + .refine((value) => value.newPassword === value.confirmNewPassword, { + message: 'Passwords do not match', + path: ['confirmNewPassword'] + }); + +export type ResetAdminUserPasswordBody = z.infer; + +export const resetAdminUserPassword = async ({ + body, + id +}: { + body: ResetAdminUserPasswordBody; + id: string; +}): Promise => { + const response = await apiClient.post(`admin/users/${id}/reset-password`, body); + + return response.json(); +}; + +type UseResetAdminUserPasswordOptions = { + mutationConfig?: MutationConfig; +}; + +export const useResetAdminUserPassword = ({ + mutationConfig +}: UseResetAdminUserPasswordOptions = {}) => + useMutation({ + ...mutationConfig, + mutationFn: resetAdminUserPassword, + meta: adminUserMutationMeta + }); diff --git a/apps/web/src/features/admin/api/restore-user.ts b/apps/web/src/features/admin/api/restore-user.ts new file mode 100644 index 0000000..78ea9f5 --- /dev/null +++ b/apps/web/src/features/admin/api/restore-user.ts @@ -0,0 +1,33 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { apiClient } from '@/lib/api-client'; +import type { MutationConfig } from '@/lib/react-query'; + +import type { AdminUserResponse } from './types'; + +import { adminKeys, adminUserMutationMeta } from './query-keys'; + +export const restoreAdminUser = async ({ id }: { id: string }): Promise => { + const response = await apiClient.post(`admin/users/${id}/restore`); + + return response.json(); +}; + +type UseRestoreAdminUserOptions = { + mutationConfig?: MutationConfig; +}; + +export const useRestoreAdminUser = ({ mutationConfig }: UseRestoreAdminUserOptions = {}) => { + const queryClient = useQueryClient(); + const { onSuccess, ...restConfig } = mutationConfig ?? {}; + + return useMutation({ + ...restConfig, + mutationFn: restoreAdminUser, + meta: adminUserMutationMeta, + onSuccess: (data, variables, ...args) => { + queryClient.invalidateQueries({ queryKey: adminKeys.userDetail(variables.id) }); + onSuccess?.(data, variables, ...args); + } + }); +}; diff --git a/apps/web/src/features/admin/api/types.ts b/apps/web/src/features/admin/api/types.ts new file mode 100644 index 0000000..2f887f1 --- /dev/null +++ b/apps/web/src/features/admin/api/types.ts @@ -0,0 +1,20 @@ +import type { ApiResult } from '@/types/api'; + +export type AdminUserRole = 'admin' | 'user'; +export type AdminUserStatus = 'active' | 'deleted' | 'all'; + +export type AdminUser = { + avatar: string | null; + createdAt: string; + deletedAt: string | null; + email: string; + id: string; + name: string | null; + role: AdminUserRole; + settings: Record; + updatedAt: string; +}; + +export type AdminUsersResponse = ApiResult; +export type AdminUserResponse = ApiResult; +export type AdminEmptyResponse = ApiResult; diff --git a/apps/web/src/features/admin/api/update-user.ts b/apps/web/src/features/admin/api/update-user.ts new file mode 100644 index 0000000..ff89986 --- /dev/null +++ b/apps/web/src/features/admin/api/update-user.ts @@ -0,0 +1,51 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { z } from 'zod'; + +import { apiClient } from '@/lib/api-client'; +import type { MutationConfig } from '@/lib/react-query'; + +import type { AdminUserResponse } from './types'; + +import { adminKeys, adminUserMutationMeta } from './query-keys'; + +export const updateAdminUserBodySchema = z + .object({ + name: z.string().min(1).max(255).optional(), + role: z.enum(['admin', 'user']).optional() + }) + .refine((value) => value.name !== undefined || value.role !== undefined, { + message: 'Provide at least one field to update' + }); + +export type UpdateAdminUserBody = z.infer; + +export const updateAdminUser = async ({ + body, + id +}: { + body: UpdateAdminUserBody; + id: string; +}): Promise => { + const response = await apiClient.patch(`admin/users/${id}`, body); + + return response.json(); +}; + +type UseUpdateAdminUserOptions = { + mutationConfig?: MutationConfig; +}; + +export const useUpdateAdminUser = ({ mutationConfig }: UseUpdateAdminUserOptions = {}) => { + const queryClient = useQueryClient(); + const { onSuccess, ...restConfig } = mutationConfig ?? {}; + + return useMutation({ + ...restConfig, + mutationFn: updateAdminUser, + meta: adminUserMutationMeta, + onSuccess: (data, variables, ...args) => { + queryClient.invalidateQueries({ queryKey: adminKeys.userDetail(variables.id) }); + onSuccess?.(data, variables, ...args); + } + }); +}; diff --git a/apps/web/src/features/auth/api/get-user.test.ts b/apps/web/src/features/auth/api/get-user.test.ts index ee205d9..08ed7e9 100644 --- a/apps/web/src/features/auth/api/get-user.test.ts +++ b/apps/web/src/features/auth/api/get-user.test.ts @@ -13,8 +13,10 @@ const API_URL = 'http://localhost:3000'; describe('auth get-user', () => { it('derives a display name from the email when the name is missing', () => { - expect(getDisplayName({ email: 'reader@loreo.test' })).toBe('reader'); - expect(getDisplayName({ email: 'reader@loreo.test', name: 'Reader User' })).toBe('Reader User'); + expect(getDisplayName({ email: 'reader@loreo.test', role: 'user' })).toBe('reader'); + expect(getDisplayName({ email: 'reader@loreo.test', name: 'Reader User', role: 'user' })).toBe( + 'Reader User' + ); }); it('maps the API response into the auth user shape', () => { @@ -22,7 +24,8 @@ describe('auth get-user', () => { result: { avatar: undefined, email: 'reader@loreo.test', - name: undefined + name: undefined, + role: 'admin' }, message: 'ok', status: 200 @@ -34,7 +37,8 @@ describe('auth get-user', () => { avatar: null, displayName: 'reader', email: 'reader@loreo.test', - name: 'reader' + name: 'reader', + role: 'admin' }, status: 200 }); @@ -47,7 +51,8 @@ describe('auth get-user', () => { result: { avatar: null, email: 'reader@loreo.test', - name: undefined + name: undefined, + role: 'user' }, message: 'ok', status: 200 @@ -67,7 +72,8 @@ describe('auth get-user', () => { avatar: null, displayName: 'reader', email: 'reader@loreo.test', - name: 'reader' + name: 'reader', + role: 'user' }, status: 200 }); diff --git a/apps/web/src/features/auth/api/get-user.ts b/apps/web/src/features/auth/api/get-user.ts index d7796f7..d2c0d0c 100644 --- a/apps/web/src/features/auth/api/get-user.ts +++ b/apps/web/src/features/auth/api/get-user.ts @@ -11,6 +11,7 @@ export type UserResult = { avatar?: string | null; email: string; name?: string | null; + role: 'admin' | 'user'; settings?: UserSettings; }; @@ -19,6 +20,7 @@ export type AuthUser = { displayName: string; email: string; name: string; + role: 'admin' | 'user'; settings?: UserSettings; }; @@ -33,6 +35,7 @@ export const mapUserResponse = (data: ApiResult): UserResponse => ({ displayName: getDisplayName(data.result), email: data.result.email, name: getDisplayName(data.result), + role: data.result.role, settings: data.result.settings } }); diff --git a/apps/web/src/features/auth/api/login.test.ts b/apps/web/src/features/auth/api/login.test.ts index 2858ae8..31dbc88 100644 --- a/apps/web/src/features/auth/api/login.test.ts +++ b/apps/web/src/features/auth/api/login.test.ts @@ -26,7 +26,8 @@ describe('auth login', () => { result: { avatar: 'https://cdn.example.com/avatar.png', email: body.email, - name: 'Reader User' + name: 'Reader User', + role: 'user' }, message: 'ok', status: 200 @@ -47,7 +48,8 @@ describe('auth login', () => { avatar: 'https://cdn.example.com/avatar.png', displayName: 'Reader User', email: 'reader@loreo.test', - name: 'Reader User' + name: 'Reader User', + role: 'user' }, status: 200 }); diff --git a/apps/web/src/features/auth/api/register.test.ts b/apps/web/src/features/auth/api/register.test.ts index 7c1fc18..b10c27c 100644 --- a/apps/web/src/features/auth/api/register.test.ts +++ b/apps/web/src/features/auth/api/register.test.ts @@ -28,7 +28,8 @@ describe('auth register', () => { result: { avatar: null, email: body.email, - name: body.name + name: body.name, + role: 'user' }, message: 'ok', status: 200 @@ -51,7 +52,8 @@ describe('auth register', () => { avatar: null, displayName: 'New Reader', email: 'new-reader@loreo.test', - name: 'New Reader' + name: 'New Reader', + role: 'user' }, status: 200 }); From 099023d76cd930c98c5242cccb90fd985574f02b Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Thu, 18 Jun 2026 06:12:36 +0700 Subject: [PATCH 4/8] feat(web): add admin dashboard with dedicated layout - Add AdminLayout with dashboard-like chrome, shield mark, back-to-app link - Separate admin route group (_protected/admin) with role guard in beforeLoad - Add accounts page with status filters and user table (desktop) / cards (mobile) - Add user edit dialog: name, role, password reset, delete, restore - Role selector uses DropdownMenu pattern matching settings page - Disable admin-target mutations (last-admin safety, server guard authoritative) - Add admin entry to UserMenu for admin role only - Add table outer border and full i18n namespace for admin copy --- apps/web/src/components/common/user-menu.tsx | 8 + apps/web/src/components/layouts/admin.tsx | 110 +++++++ .../admin/components/admin-user-dialog.tsx | 285 ++++++++++++++++++ .../admin/components/admin-user-list.tsx | 111 +++++++ apps/web/src/pages/admin.tsx | 63 ++++ apps/web/src/routeTree.gen.ts | 50 +++ apps/web/src/routes/_protected/admin.tsx | 26 ++ .../web/src/routes/_protected/admin/index.tsx | 10 + apps/web/src/translations/en.json | 55 ++++ 9 files changed, 718 insertions(+) create mode 100644 apps/web/src/components/layouts/admin.tsx create mode 100644 apps/web/src/features/admin/components/admin-user-dialog.tsx create mode 100644 apps/web/src/features/admin/components/admin-user-list.tsx create mode 100644 apps/web/src/pages/admin.tsx create mode 100644 apps/web/src/routes/_protected/admin.tsx create mode 100644 apps/web/src/routes/_protected/admin/index.tsx diff --git a/apps/web/src/components/common/user-menu.tsx b/apps/web/src/components/common/user-menu.tsx index 38ed81a..47adcee 100644 --- a/apps/web/src/components/common/user-menu.tsx +++ b/apps/web/src/components/common/user-menu.tsx @@ -4,6 +4,7 @@ import { GearIcon, MonitorIcon, MoonIcon, + ShieldIcon, SignOutIcon, SunIcon } from '@phosphor-icons/react'; @@ -126,6 +127,13 @@ export function UserMenu({ align = 'end', contentClassName, trigger }: UserMenuP + {user?.result?.role === 'admin' && ( + navigate({ to: '/admin' })}> + + {t('userMenu.admin')} + + )} + navigate({ to: '/settings' })}> {t('userMenu.settings')} diff --git a/apps/web/src/components/layouts/admin.tsx b/apps/web/src/components/layouts/admin.tsx new file mode 100644 index 0000000..565f9da --- /dev/null +++ b/apps/web/src/components/layouts/admin.tsx @@ -0,0 +1,110 @@ +import { ArrowLeftIcon, ShieldIcon } from '@phosphor-icons/react'; +import { Link, Outlet, useLocation } from '@tanstack/react-router'; +import { type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useGetUser } from '@/features/auth/api/get-user'; + +import { cn } from '@/lib/utils'; + +import { UserMenu } from '../common/user-menu'; +import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar'; +import { Button } from '../ui/button'; + +function getInitials(name: string): string { + return name + .split(' ') + .map((word) => word[0]) + .slice(0, 2) + .join('') + .toUpperCase(); +} + +interface AdminLayoutProps { + children?: ReactNode; +} + +function AdminLayout({ children }: AdminLayoutProps) { + const { t } = useTranslation(); + const { pathname } = useLocation(); + const { data: user } = useGetUser(); + + const displayName = user?.result?.displayName ?? t('userMenu.fallbackName'); + const avatarPreview = user?.result?.avatar ?? null; + const initials = getInitials(displayName); + + const navItems = [{ label: t('admin.nav.accounts'), to: '/admin' }] as const; + + return ( +
+
+
+
+
+
+ +
+
+

+ {t('admin.layout.title')} +

+

+ {t('admin.layout.subtitle')} +

+
+
+ +
+ + + + + + {avatarPreview && } + {initials} + + + } + /> +
+
+ + +
+
+ +
{children ?? }
+
+ ); +} + +export default AdminLayout; diff --git a/apps/web/src/features/admin/components/admin-user-dialog.tsx b/apps/web/src/features/admin/components/admin-user-dialog.tsx new file mode 100644 index 0000000..0ebb5e1 --- /dev/null +++ b/apps/web/src/features/admin/components/admin-user-dialog.tsx @@ -0,0 +1,285 @@ +import { CaretDownIcon, CheckIcon } from '@phosphor-icons/react'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Spinner } from '@/components/ui/spinner'; + +import { useDeleteAdminUser } from '@/features/admin/api/delete-user'; +import { useGetAdminUser } from '@/features/admin/api/get-user'; +import { useResetAdminUserPassword } from '@/features/admin/api/reset-user-password'; +import { useRestoreAdminUser } from '@/features/admin/api/restore-user'; +import { useUpdateAdminUser } from '@/features/admin/api/update-user'; + +import { cn } from '@/lib/utils'; + +const ROLE_VALUES = ['admin', 'user'] as const; +type RoleValue = (typeof ROLE_VALUES)[number]; + +interface AdminUserDialogProps { + onClose: () => void; + userId: string | null; +} + +export function AdminUserDialog({ onClose, userId }: AdminUserDialogProps) { + const { t } = useTranslation(); + const isOpen = userId !== null; + + const { data: userData, isLoading: isUserLoading } = useGetAdminUser({ + id: userId ?? '' + }); + + const user = userData?.result; + + const [name, setName] = useState(''); + const [role, setRole] = useState('user'); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showReset, setShowReset] = useState(false); + + const updateMutation = useUpdateAdminUser({ + mutationConfig: { + onSuccess: () => { + onClose(); + } + } + }); + + const resetMutation = useResetAdminUserPassword({ + mutationConfig: { + onSuccess: () => { + setShowReset(false); + setNewPassword(''); + setConfirmPassword(''); + } + } + }); + + const deleteMutation = useDeleteAdminUser({ + mutationConfig: { + onSuccess: () => { + onClose(); + } + } + }); + + const restoreMutation = useRestoreAdminUser({ + mutationConfig: { + onSuccess: () => { + onClose(); + } + } + }); + + useEffect(() => { + if (user) { + setName(user.name ?? ''); + setRole(user.role); + setShowReset(false); + setNewPassword(''); + setConfirmPassword(''); + } + }, [user]); + + const isAdminTarget = user?.role === 'admin'; + const nameChanged = name.trim() !== (user?.name ?? '').trim(); + const roleChanged = role !== user?.role; + const canSave = (nameChanged || roleChanged) && !isAdminTarget; + + const handleUpdate = () => { + if (!userId || !canSave) return; + const body: { name?: string; role?: RoleValue } = {}; + if (nameChanged) body.name = name.trim(); + if (roleChanged) body.role = role; + updateMutation.mutate({ body, id: userId }); + }; + + const handleResetPassword = () => { + if (!userId || !newPassword || !confirmPassword) return; + resetMutation.mutate({ + body: { confirmNewPassword: confirmPassword, newPassword }, + id: userId + }); + }; + + const handleDelete = () => { + if (!userId) return; + deleteMutation.mutate({ id: userId }); + }; + + const handleRestore = () => { + if (!userId) return; + restoreMutation.mutate({ id: userId }); + }; + + const isMutating = + updateMutation.isPending || + resetMutation.isPending || + deleteMutation.isPending || + restoreMutation.isPending; + + return ( + !open && onClose()} open={isOpen}> + + + {t('admin.dialog.editTitle')} + {user?.email} + + + {isUserLoading ? ( +
+ +
+ ) : user ? ( +
+
+ + setName(e.target.value)} + value={name} + /> +
+ +
+ + + + } + > + {t(`admin.role.${role}`)} + + + + {ROLE_VALUES.map((value) => ( + setRole(value)}> + {t(`admin.role.${value}`)} + {role === value && } + + ))} + + +
+ + {isAdminTarget ? ( +

{t('admin.user.adminProtected')}

+ ) : showReset ? ( +
+

{t('admin.dialog.resetPassword')}

+
+ + setNewPassword(e.target.value)} + type="password" + value={newPassword} + /> +
+
+ + setConfirmPassword(e.target.value)} + type="password" + value={confirmPassword} + /> +
+ {resetMutation.isError && ( +

+ {resetMutation.error?.message ?? t('admin.dialog.resetError')} +

+ )} +
+ + +
+
+ ) : ( + + )} + + {updateMutation.isError && ( +

+ {updateMutation.error?.message ?? t('admin.dialog.updateError')} +

+ )} +
+ ) : null} + + + {user?.deletedAt ? ( + + ) : ( + + )} + + + +
+
+ ); +} diff --git a/apps/web/src/features/admin/components/admin-user-list.tsx b/apps/web/src/features/admin/components/admin-user-list.tsx new file mode 100644 index 0000000..5f0680d --- /dev/null +++ b/apps/web/src/features/admin/components/admin-user-list.tsx @@ -0,0 +1,111 @@ +import { ShieldIcon, UserIcon } from '@phosphor-icons/react'; +import { useTranslation } from 'react-i18next'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@/components/ui/table'; + +import type { AdminUser } from '@/features/admin/api/types'; + +import { cn } from '@/lib/utils'; + +interface AdminUserListProps { + onEditUser: (id: string) => void; + status: 'active' | 'deleted' | 'all'; + users: AdminUser[]; +} + +function UserRoleBadge({ role }: { role: AdminUser['role'] }) { + return ( + + {role === 'admin' ? : } + {role} + + ); +} + +export function AdminUserList({ onEditUser, status, users }: AdminUserListProps) { + const { t } = useTranslation(); + + if (users.length === 0) { + return ( +
+

+ {status === 'deleted' ? t('admin.page.emptyDeleted') : t('admin.page.emptyActive')} +

+
+ ); + } + + return ( + <> + {/* Desktop table */} +
+ + + + {t('admin.user.name')} + {t('admin.user.email')} + {t('admin.user.role')} + {t('admin.user.created')} + {t('admin.user.actions')} + + + + {users.map((user) => ( + + {user.name ?? t('admin.user.noName')} + {user.email} + + + + {new Date(user.createdAt).toLocaleDateString()} + + + + + ))} + +
+
+ + {/* Mobile cards */} +
+ {users.map((user) => ( +
+
+
+

{user.name ?? t('admin.user.noName')}

+

{user.email}

+
+ +
+
+ {t('admin.user.created')}: {new Date(user.createdAt).toLocaleDateString()} +
+
+ +
+
+ ))} +
+ + ); +} diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx new file mode 100644 index 0000000..f5d0020 --- /dev/null +++ b/apps/web/src/pages/admin.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; + +import { useGetAdminUsers } from '@/features/admin/api/get-users'; +import { AdminUserDialog } from '@/features/admin/components/admin-user-dialog'; +import { AdminUserList } from '@/features/admin/components/admin-user-list'; + +function AdminPage() { + const { t } = useTranslation(); + const [status, setStatus] = useState<'active' | 'deleted' | 'all'>('active'); + const [selectedUserId, setSelectedUserId] = useState(null); + + const { data, isLoading, isError, refetch } = useGetAdminUsers({ + params: { limit: 50, status } + }); + + const users = data?.result ?? []; + + return ( +
+
+

{t('admin.page.title')}

+

{t('admin.page.description')}

+
+ +
+ {(['active', 'deleted', 'all'] as const).map((s) => ( + + ))} +
+ + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

{t('admin.page.error')}

+ +
+ ) : ( + + )} + + setSelectedUserId(null)} userId={selectedUserId} /> +
+ ); +} + +export default AdminPage; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 7ae4cab..07635b3 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,9 +11,11 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ProtectedRouteImport } from './routes/_protected' import { Route as AuthRouteImport } from './routes/_auth' +import { Route as ProtectedAdminRouteImport } from './routes/_protected/admin' import { Route as ProtectedWithLayoutRouteImport } from './routes/_protected/_with-layout' import { Route as AuthRegisterRouteImport } from './routes/_auth/register' import { Route as AuthLoginRouteImport } from './routes/_auth/login' +import { Route as ProtectedAdminIndexRouteImport } from './routes/_protected/admin/index' import { Route as ProtectedWithLayoutIndexRouteImport } from './routes/_protected/_with-layout/index' import { Route as ProtectedArticlesIdRouteImport } from './routes/_protected/articles/$id' import { Route as ProtectedWithLayoutManageTagsRouteImport } from './routes/_protected/_with-layout/manage-tags' @@ -30,6 +32,11 @@ const AuthRoute = AuthRouteImport.update({ id: '/_auth', getParentRoute: () => rootRouteImport, } as any) +const ProtectedAdminRoute = ProtectedAdminRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => ProtectedRoute, +} as any) const ProtectedWithLayoutRoute = ProtectedWithLayoutRouteImport.update({ id: '/_with-layout', getParentRoute: () => ProtectedRoute, @@ -44,6 +51,11 @@ const AuthLoginRoute = AuthLoginRouteImport.update({ path: '/login', getParentRoute: () => AuthRoute, } as any) +const ProtectedAdminIndexRoute = ProtectedAdminIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => ProtectedAdminRoute, +} as any) const ProtectedWithLayoutIndexRoute = ProtectedWithLayoutIndexRouteImport.update({ id: '/', @@ -90,8 +102,10 @@ export interface FileRoutesByFullPath { '/': typeof ProtectedWithLayoutIndexRoute '/login': typeof AuthLoginRoute '/register': typeof AuthRegisterRoute + '/admin': typeof ProtectedAdminRouteWithChildren '/manage-tags': typeof ProtectedWithLayoutManageTagsRoute '/articles/$id': typeof ProtectedArticlesIdRoute + '/admin/': typeof ProtectedAdminIndexRoute '/articles/': typeof ProtectedWithLayoutArticlesIndexRoute '/settings/': typeof ProtectedWithLayoutSettingsIndexRoute '/settings/import-articles/$sessionId': typeof ProtectedWithLayoutSettingsImportArticlesSessionIdRoute @@ -103,6 +117,7 @@ export interface FileRoutesByTo { '/register': typeof AuthRegisterRoute '/manage-tags': typeof ProtectedWithLayoutManageTagsRoute '/articles/$id': typeof ProtectedArticlesIdRoute + '/admin': typeof ProtectedAdminIndexRoute '/articles': typeof ProtectedWithLayoutArticlesIndexRoute '/settings': typeof ProtectedWithLayoutSettingsIndexRoute '/settings/import-articles/$sessionId': typeof ProtectedWithLayoutSettingsImportArticlesSessionIdRoute @@ -115,9 +130,11 @@ export interface FileRoutesById { '/_auth/login': typeof AuthLoginRoute '/_auth/register': typeof AuthRegisterRoute '/_protected/_with-layout': typeof ProtectedWithLayoutRouteWithChildren + '/_protected/admin': typeof ProtectedAdminRouteWithChildren '/_protected/_with-layout/manage-tags': typeof ProtectedWithLayoutManageTagsRoute '/_protected/articles/$id': typeof ProtectedArticlesIdRoute '/_protected/_with-layout/': typeof ProtectedWithLayoutIndexRoute + '/_protected/admin/': typeof ProtectedAdminIndexRoute '/_protected/_with-layout/articles/': typeof ProtectedWithLayoutArticlesIndexRoute '/_protected/_with-layout/settings/': typeof ProtectedWithLayoutSettingsIndexRoute '/_protected/_with-layout/settings/import-articles/$sessionId': typeof ProtectedWithLayoutSettingsImportArticlesSessionIdRoute @@ -129,8 +146,10 @@ export interface FileRouteTypes { | '/' | '/login' | '/register' + | '/admin' | '/manage-tags' | '/articles/$id' + | '/admin/' | '/articles/' | '/settings/' | '/settings/import-articles/$sessionId' @@ -142,6 +161,7 @@ export interface FileRouteTypes { | '/register' | '/manage-tags' | '/articles/$id' + | '/admin' | '/articles' | '/settings' | '/settings/import-articles/$sessionId' @@ -153,9 +173,11 @@ export interface FileRouteTypes { | '/_auth/login' | '/_auth/register' | '/_protected/_with-layout' + | '/_protected/admin' | '/_protected/_with-layout/manage-tags' | '/_protected/articles/$id' | '/_protected/_with-layout/' + | '/_protected/admin/' | '/_protected/_with-layout/articles/' | '/_protected/_with-layout/settings/' | '/_protected/_with-layout/settings/import-articles/$sessionId' @@ -183,6 +205,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthRouteImport parentRoute: typeof rootRouteImport } + '/_protected/admin': { + id: '/_protected/admin' + path: '/admin' + fullPath: '/admin' + preLoaderRoute: typeof ProtectedAdminRouteImport + parentRoute: typeof ProtectedRoute + } '/_protected/_with-layout': { id: '/_protected/_with-layout' path: '' @@ -204,6 +233,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthLoginRouteImport parentRoute: typeof AuthRoute } + '/_protected/admin/': { + id: '/_protected/admin/' + path: '/' + fullPath: '/admin/' + preLoaderRoute: typeof ProtectedAdminIndexRouteImport + parentRoute: typeof ProtectedAdminRoute + } '/_protected/_with-layout/': { id: '/_protected/_with-layout/' path: '/' @@ -291,13 +327,27 @@ const ProtectedWithLayoutRouteChildren: ProtectedWithLayoutRouteChildren = { const ProtectedWithLayoutRouteWithChildren = ProtectedWithLayoutRoute._addFileChildren(ProtectedWithLayoutRouteChildren) +interface ProtectedAdminRouteChildren { + ProtectedAdminIndexRoute: typeof ProtectedAdminIndexRoute +} + +const ProtectedAdminRouteChildren: ProtectedAdminRouteChildren = { + ProtectedAdminIndexRoute: ProtectedAdminIndexRoute, +} + +const ProtectedAdminRouteWithChildren = ProtectedAdminRoute._addFileChildren( + ProtectedAdminRouteChildren, +) + interface ProtectedRouteChildren { ProtectedWithLayoutRoute: typeof ProtectedWithLayoutRouteWithChildren + ProtectedAdminRoute: typeof ProtectedAdminRouteWithChildren ProtectedArticlesIdRoute: typeof ProtectedArticlesIdRoute } const ProtectedRouteChildren: ProtectedRouteChildren = { ProtectedWithLayoutRoute: ProtectedWithLayoutRouteWithChildren, + ProtectedAdminRoute: ProtectedAdminRouteWithChildren, ProtectedArticlesIdRoute: ProtectedArticlesIdRoute, } diff --git a/apps/web/src/routes/_protected/admin.tsx b/apps/web/src/routes/_protected/admin.tsx new file mode 100644 index 0000000..e97d1a5 --- /dev/null +++ b/apps/web/src/routes/_protected/admin.tsx @@ -0,0 +1,26 @@ +import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'; + +import AdminLayout from '@/components/layouts/admin'; + +export const Route = createFileRoute('/_protected/admin')({ + beforeLoad: async ({ context }) => { + try { + const auth = await context.auth.ensureData(); + if (!auth || auth.result.role !== 'admin') { + throw redirect({ to: '/' }); + } + } catch (error) { + if (error instanceof Response) throw error; + throw redirect({ to: '/login' }); + } + }, + component: AdminLayoutRoute +}); + +function AdminLayoutRoute() { + return ( + + + + ); +} diff --git a/apps/web/src/routes/_protected/admin/index.tsx b/apps/web/src/routes/_protected/admin/index.tsx new file mode 100644 index 0000000..6edaf1b --- /dev/null +++ b/apps/web/src/routes/_protected/admin/index.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/react-router'; + +import i18n from '@/lib/i18n'; + +import AdminPage from '@/pages/admin'; + +export const Route = createFileRoute('/_protected/admin/')({ + head: () => ({ meta: [{ title: i18n.t('routes.admin.metaTitle') }] }), + component: AdminPage +}); diff --git a/apps/web/src/translations/en.json b/apps/web/src/translations/en.json index d236545..63bf799 100644 --- a/apps/web/src/translations/en.json +++ b/apps/web/src/translations/en.json @@ -98,9 +98,11 @@ }, "userMenu": { "fallbackName": "User", + "open": "Open menu for {{name}}", "switchTheme": "Switch theme", "settings": "Settings", "logOut": "Log out", + "admin": "Admin", "themes": { "system": "System", "light": "Light", @@ -141,6 +143,9 @@ "settings": { "metaTitle": "Settings | Loreo" }, + "admin": { + "metaTitle": "Admin | Loreo" + }, "manageTags": { "metaTitle": "Manage Tags | Loreo" }, @@ -740,5 +745,55 @@ "failedLoadMore": "Failed to load more items", "processingQueue": "Processing Queue" } + }, + "admin": { + "layout": { + "title": "Admin", + "subtitle": "Account and service management", + "backToApp": "Back to app", + "navLabel": "Admin sections" + }, + "nav": { + "accounts": "Accounts", + "connections": "Connections" + }, + "page": { + "title": "Accounts", + "description": "Manage user accounts, roles, and access.", + "error": "Couldn't load accounts.", + "tryAgain": "Try again", + "emptyActive": "No active accounts.", + "emptyDeleted": "No deleted accounts.", + "status": { + "active": "Active", + "deleted": "Deleted", + "all": "All" + } + }, + "user": { + "name": "Name", + "email": "Email", + "role": "Role", + "created": "Created", + "actions": "Actions", + "noName": "No name", + "edit": "Manage", + "delete": "Delete account", + "restore": "Restore account", + "adminProtected": "Admin accounts can't be modified from here." + }, + "role": { + "admin": "Admin", + "user": "User" + }, + "dialog": { + "editTitle": "Manage account", + "resetPassword": "Reset password", + "newPassword": "New password", + "confirmPassword": "Confirm new password", + "savePassword": "Save password", + "updateError": "Couldn't update account.", + "resetError": "Couldn't reset password." + } } } From 46f219fc02023ebf0e1723d9c71ba754792509e7 Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Thu, 18 Jun 2026 06:21:04 +0700 Subject: [PATCH 5/8] feat(admin): add service connections health check and per-user article counts Phase 4b - service connections: - Add GET /admin/health/connections admin route - Add browser.service checkHealth() probe with 5s timeout - Add admin-health.service aggregating connection checks (camoufox first) - Break import cycle via lazy import of browser.service - Add admin connections panel with 30s auto-refresh - Calm status colors: success/warning/danger, no urgency framing - Add connections route and nav item Phase 4c - per-user article counts: - Add countArticlesByUser() repository method (groupBy aggregation) - Enrich admin user list response with articleCount - Add articles column to admin user table (desktop) and mobile card - Use tabular-nums for stable numeric layout Verification: - pnpm --filter @loreo/server typecheck passed - pnpm --filter @loreo/web typecheck passed - pnpm --filter @loreo/server exec vitest --run admin files passed: 24 tests --- .../src/repositories/auth.repository.ts | 16 ++- .../server/src/routes/admin/admin.handlers.ts | 8 ++ apps/server/src/routes/admin/admin.index.ts | 3 +- apps/server/src/routes/admin/admin.routes.ts | 30 ++++- apps/server/src/routes/files/files.test.ts | 3 +- .../src/services/admin-health.service.ts | 34 ++++++ .../services/admin-health.service.types.ts | 1 + apps/server/src/services/admin.service.ts | 6 +- apps/server/src/services/browser.service.ts | 18 +++ apps/server/src/tests/in-memory/auth.ts | 2 + apps/web/src/components/layouts/admin.tsx | 5 +- .../src/features/admin/api/get-connections.ts | 45 +++++++ apps/web/src/features/admin/api/types.ts | 1 + .../components/admin-connections-panel.tsx | 110 ++++++++++++++++++ .../admin/components/admin-user-list.tsx | 4 + apps/web/src/routeTree.gen.ts | 22 ++++ .../routes/_protected/admin/connections.tsx | 10 ++ apps/web/src/translations/en.json | 17 ++- 18 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/services/admin-health.service.ts create mode 100644 apps/server/src/services/admin-health.service.types.ts create mode 100644 apps/web/src/features/admin/api/get-connections.ts create mode 100644 apps/web/src/features/admin/components/admin-connections-panel.tsx create mode 100644 apps/web/src/routes/_protected/admin/connections.tsx diff --git a/apps/server/src/repositories/auth.repository.ts b/apps/server/src/repositories/auth.repository.ts index 27474b5..507d3a0 100644 --- a/apps/server/src/repositories/auth.repository.ts +++ b/apps/server/src/repositories/auth.repository.ts @@ -2,7 +2,7 @@ import { and, count, desc, eq, isNotNull, isNull } from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import type * as schema from '@/db/schemas/index.js'; -import { usersTable } from '@/db/schemas/index.js'; +import { linksTable, usersTable } from '@/db/schemas/index.js'; import { logger } from '@/lib/logger.js'; @@ -65,6 +65,7 @@ export interface AuthRepository { updateDeletedAt(id: string, deletedAt: string | null): Promise; countUsers(): Promise; countActiveAdmins(): Promise; + countArticlesByUser(): Promise>; } function getListUsersWhere(status: ListUsersStatus) { @@ -255,6 +256,19 @@ export function createDrizzleAuthAdapter(db: DrizzleClient): AuthRepository { .where(and(eq(usersTable.role, 'admin'), isNull(usersTable.deletedAt))); return Number(users?.count ?? 0); + }, + + async countArticlesByUser() { + const rows = await db + .select({ userId: linksTable.userId, count: count() }) + .from(linksTable) + .groupBy(linksTable.userId); + + const counts: Record = {}; + for (const row of rows) { + counts[row.userId] = Number(row.count); + } + return counts; } }; } diff --git a/apps/server/src/routes/admin/admin.handlers.ts b/apps/server/src/routes/admin/admin.handlers.ts index 38a1d4b..03d977d 100644 --- a/apps/server/src/routes/admin/admin.handlers.ts +++ b/apps/server/src/routes/admin/admin.handlers.ts @@ -2,10 +2,12 @@ import { demoModeForbiddenResponse, isDemoMode } from '@/lib/demo-mode.js'; import { errorResponse, HttpStatus, successResponse } from '@/lib/response.js'; import type { AppRouteHandler } from '@/lib/types.js'; +import { adminHealthService } from '@/services/admin-health.service.js'; import { AdminService, AdminServiceError } from '@/services/admin.service.js'; import type { GetUserRoute, + ListConnectionsRoute, ListUsersRoute, ResetPasswordRoute, RestoreUserRoute, @@ -137,3 +139,9 @@ export const restoreUser: AppRouteHandler = async (c) => { return c.json(response, response.status); } }; + +export const listConnections: AppRouteHandler = async (c) => { + const connections = await adminHealthService.checkConnections(); + const response = successResponse(connections, 'Service connections fetched successfully'); + return c.json(response, response.status); +}; diff --git a/apps/server/src/routes/admin/admin.index.ts b/apps/server/src/routes/admin/admin.index.ts index baed855..3a70f89 100644 --- a/apps/server/src/routes/admin/admin.index.ts +++ b/apps/server/src/routes/admin/admin.index.ts @@ -9,6 +9,7 @@ const router = createRouter() .openapi(routes.updateUser, handlers.updateUser) .openapi(routes.resetPassword, handlers.resetPassword) .openapi(routes.softDeleteUser, handlers.softDeleteUser) - .openapi(routes.restoreUser, handlers.restoreUser); + .openapi(routes.restoreUser, handlers.restoreUser) + .openapi(routes.listConnections, handlers.listConnections); export default router; diff --git a/apps/server/src/routes/admin/admin.routes.ts b/apps/server/src/routes/admin/admin.routes.ts index 2784654..bb0aee9 100644 --- a/apps/server/src/routes/admin/admin.routes.ts +++ b/apps/server/src/routes/admin/admin.routes.ts @@ -17,7 +17,8 @@ const adminSafeUserSchema = z.object({ settings: z.record(z.string(), z.unknown()), deletedAt: z.string().nullable(), createdAt: z.string(), - updatedAt: z.string() + updatedAt: z.string(), + articleCount: z.number().optional() }); const userParamsSchema = z.object({ id: z.uuid() }); @@ -42,6 +43,14 @@ const resetPasswordSchema = z path: ['confirmNewPassword'] }); +const connectionCheckSchema = z.object({ + id: z.string(), + label: z.string(), + latencyMs: z.number().optional(), + message: z.string().optional(), + status: z.enum(['ok', 'degraded', 'down']) +}); + export const listUsers = createRoute({ tags, method: 'get', @@ -205,9 +214,28 @@ export const restoreUser = createRoute({ } }); +export const listConnections = createRoute({ + tags, + method: 'get', + path: '/admin/health/connections', + middleware: [currentUser, adminUser], + responses: { + [HttpStatus.OK]: jsonContent( + successResponseSchema(z.array(connectionCheckSchema)), + 'Service connections fetched successfully' + ), + [HttpStatus.UNAUTHORIZED]: jsonContent( + errorResponseSchema(HttpStatus.UNAUTHORIZED), + 'Unauthorized' + ), + [HttpStatus.FORBIDDEN]: jsonContent(errorResponseSchema(HttpStatus.FORBIDDEN), 'Forbidden') + } +}); + export type ListUsersRoute = typeof listUsers; export type GetUserRoute = typeof getUser; export type UpdateUserRoute = typeof updateUser; export type ResetPasswordRoute = typeof resetPassword; export type SoftDeleteUserRoute = typeof softDeleteUser; export type RestoreUserRoute = typeof restoreUser; +export type ListConnectionsRoute = typeof listConnections; diff --git a/apps/server/src/routes/files/files.test.ts b/apps/server/src/routes/files/files.test.ts index 291fd01..d69261a 100644 --- a/apps/server/src/routes/files/files.test.ts +++ b/apps/server/src/routes/files/files.test.ts @@ -92,7 +92,8 @@ function createFakeAuthRepository(user: UserWithoutPassword): AuthRepository { updatePassword: async () => null, updateDeletedAt: async () => user, countUsers: async () => 1, - countActiveAdmins: async () => 0 + countActiveAdmins: async () => 0, + countArticlesByUser: async () => ({}) } satisfies AuthRepository; } diff --git a/apps/server/src/services/admin-health.service.ts b/apps/server/src/services/admin-health.service.ts new file mode 100644 index 0000000..6c7d150 --- /dev/null +++ b/apps/server/src/services/admin-health.service.ts @@ -0,0 +1,34 @@ +import type { ConnectionStatus } from './admin-health.service.types.js'; + +export interface ConnectionCheck { + id: string; + label: string; + latencyMs?: number; + message?: string; + status: ConnectionStatus; +} + +export interface AdminHealthService { + checkConnections(): Promise; +} + +export class DefaultAdminHealthService implements AdminHealthService { + async checkConnections(): Promise { + const browser = await this.checkBrowser(); + return [browser]; + } + + private async checkBrowser(): Promise { + const { browserService } = await import('./browser.service.js'); + const result = await browserService.checkHealth(); + return { + id: 'browser', + label: 'Headless browser (camoufox)', + latencyMs: result.latencyMs, + message: result.message, + status: result.status + }; + } +} + +export const adminHealthService = new DefaultAdminHealthService(); diff --git a/apps/server/src/services/admin-health.service.types.ts b/apps/server/src/services/admin-health.service.types.ts new file mode 100644 index 0000000..bc909bc --- /dev/null +++ b/apps/server/src/services/admin-health.service.types.ts @@ -0,0 +1 @@ +export type ConnectionStatus = 'ok' | 'degraded' | 'down'; diff --git a/apps/server/src/services/admin.service.ts b/apps/server/src/services/admin.service.ts index ed1a244..962cec6 100644 --- a/apps/server/src/services/admin.service.ts +++ b/apps/server/src/services/admin.service.ts @@ -26,7 +26,11 @@ export class AdminService { constructor(private repo: AuthRepository = createDrizzleAuthAdapter(db)) {} async listUsers(options: ListUsersOptions = {}): Promise { - return await this.repo.listUsers(options); + const [users, articleCounts] = await Promise.all([ + this.repo.listUsers(options), + this.repo.countArticlesByUser() + ]); + return users.map((user) => ({ ...user, articleCount: articleCounts[user.id] ?? 0 })); } async getUser(userId: string): Promise { diff --git a/apps/server/src/services/browser.service.ts b/apps/server/src/services/browser.service.ts index a168b9c..b68482b 100644 --- a/apps/server/src/services/browser.service.ts +++ b/apps/server/src/services/browser.service.ts @@ -126,6 +126,24 @@ class BrowserService { await this.releaseContext(context); } } + + async checkHealth(): Promise<{ latencyMs?: number; message?: string; status: 'ok' | 'down' }> { + const start = Date.now(); + + try { + const context = await Promise.race([ + this.acquireContext(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Health check timeout')), 5_000) + ) + ]); + await this.releaseContext(context); + return { latencyMs: Date.now() - start, status: 'ok' }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown browser error'; + return { message, status: 'down' }; + } + } } export const browserService = new BrowserService(); diff --git a/apps/server/src/tests/in-memory/auth.ts b/apps/server/src/tests/in-memory/auth.ts index 5b59c21..4fd9723 100644 --- a/apps/server/src/tests/in-memory/auth.ts +++ b/apps/server/src/tests/in-memory/auth.ts @@ -75,6 +75,8 @@ export function createInMemoryAuthAdapter(): AuthRepository { Array.from(usersById.values()).filter((user) => user.role === 'admin' && !user.deletedAt) .length, + countArticlesByUser: async () => ({}), + update: async (id, updates) => { const user = usersById.get(id); if (!user) return null; diff --git a/apps/web/src/components/layouts/admin.tsx b/apps/web/src/components/layouts/admin.tsx index 565f9da..df30fc4 100644 --- a/apps/web/src/components/layouts/admin.tsx +++ b/apps/web/src/components/layouts/admin.tsx @@ -33,7 +33,10 @@ function AdminLayout({ children }: AdminLayoutProps) { const avatarPreview = user?.result?.avatar ?? null; const initials = getInitials(displayName); - const navItems = [{ label: t('admin.nav.accounts'), to: '/admin' }] as const; + const navItems = [ + { label: t('admin.nav.accounts'), to: '/admin' }, + { label: t('admin.nav.connections'), to: '/admin/connections' } + ] as const; return (
diff --git a/apps/web/src/features/admin/api/get-connections.ts b/apps/web/src/features/admin/api/get-connections.ts new file mode 100644 index 0000000..80cafc3 --- /dev/null +++ b/apps/web/src/features/admin/api/get-connections.ts @@ -0,0 +1,45 @@ +import { queryOptions, useQuery } from '@tanstack/react-query'; + +import { apiClient } from '@/lib/api-client'; +import type { QueryConfig } from '@/lib/react-query'; + +import { adminKeys } from './query-keys'; + +export type ConnectionStatus = 'ok' | 'degraded' | 'down'; + +export type ConnectionCheck = { + id: string; + label: string; + latencyMs?: number; + message?: string; + status: ConnectionStatus; +}; + +export type AdminConnectionsResponse = { + message: string; + result: ConnectionCheck[]; + status: number; +}; + +export const getAdminConnections = async (): Promise => { + const response = await apiClient.get('admin/health/connections'); + + return response.json(); +}; + +export const getAdminConnectionsQueryOptions = () => + queryOptions({ + queryFn: getAdminConnections, + queryKey: [...adminKeys.all, 'connections'], + refetchInterval: 30_000 + }); + +type UseGetAdminConnectionsOptions = { + queryConfig?: QueryConfig; +}; + +export const useGetAdminConnections = ({ queryConfig }: UseGetAdminConnectionsOptions = {}) => + useQuery({ + ...getAdminConnectionsQueryOptions(), + ...queryConfig + }); diff --git a/apps/web/src/features/admin/api/types.ts b/apps/web/src/features/admin/api/types.ts index 2f887f1..5f9061b 100644 --- a/apps/web/src/features/admin/api/types.ts +++ b/apps/web/src/features/admin/api/types.ts @@ -13,6 +13,7 @@ export type AdminUser = { role: AdminUserRole; settings: Record; updatedAt: string; + articleCount?: number; }; export type AdminUsersResponse = ApiResult; diff --git a/apps/web/src/features/admin/components/admin-connections-panel.tsx b/apps/web/src/features/admin/components/admin-connections-panel.tsx new file mode 100644 index 0000000..182c38a --- /dev/null +++ b/apps/web/src/features/admin/components/admin-connections-panel.tsx @@ -0,0 +1,110 @@ +import { + CheckCircleIcon, + SpinnerIcon, + WarningCircleIcon, + XCircleIcon +} from '@phosphor-icons/react'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; + +import { type ConnectionCheck, useGetAdminConnections } from '@/features/admin/api/get-connections'; + +import { cn } from '@/lib/utils'; + +function StatusIcon({ status }: { status: ConnectionCheck['status'] }) { + if (status === 'ok') { + return ; + } + if (status === 'degraded') { + return ; + } + return ; +} + +function ConnectionRow({ check }: { check: ConnectionCheck }) { + const { t } = useTranslation(); + const statusKey = `admin.connections.status.${check.status}`; + + return ( +
  • +
    + +
    +

    {check.label}

    + {check.message && ( +

    {check.message}

    + )} +
    +
    +
    +

    + {t(statusKey)} +

    + {typeof check.latencyMs === 'number' && ( +

    + {t('admin.connections.latency', { ms: check.latencyMs })} +

    + )} +
    +
  • + ); +} + +export function AdminConnectionsPanel() { + const { t } = useTranslation(); + const { data, isLoading, isError, refetch, isFetching } = useGetAdminConnections(); + + return ( +
    +
    +
    +
    +

    + {t('admin.connections.title')} +

    +

    + {t('admin.connections.description')} +

    +
    + +
    +
    + + {isLoading ? ( +
    + +
    + ) : isError ? ( +
    +

    {t('admin.connections.error')}

    + +
    + ) : ( +
      + {data?.result.map((check) => ( + + ))} +
    + )} + +

    + + {t('admin.connections.autoRefresh')} +

    +
    + ); +} diff --git a/apps/web/src/features/admin/components/admin-user-list.tsx b/apps/web/src/features/admin/components/admin-user-list.tsx index 5f0680d..e5f893c 100644 --- a/apps/web/src/features/admin/components/admin-user-list.tsx +++ b/apps/web/src/features/admin/components/admin-user-list.tsx @@ -54,6 +54,7 @@ export function AdminUserList({ onEditUser, status, users }: AdminUserListProps) {t('admin.user.name')} {t('admin.user.email')} {t('admin.user.role')} + {t('admin.user.articles')} {t('admin.user.created')} {t('admin.user.actions')} @@ -66,6 +67,7 @@ export function AdminUserList({ onEditUser, status, users }: AdminUserListProps) + {user.articleCount ?? 0} {new Date(user.createdAt).toLocaleDateString()}
    {t('admin.user.created')}: {new Date(user.createdAt).toLocaleDateString()} + {' · '} + {t('admin.user.articlesCount', { count: user.articleCount ?? 0 })}