diff --git a/CHANGELOG.md b/CHANGELOG.md index 59f52c3..205b00d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Add admin dashboard with dedicated layout for account management and service health visibility. +- Add admin account management: list, detail, update name/role, reset password, soft delete, and restore users. +- Expose user role in current-user response; client role gates admin navigation affordance only. +- Add service connections health check with camoufox browser probe. +- Add per-user article count column on admin accounts table. +- Add last-admin protection: server blocks demotion/deletion that would leave zero active admins; UI disables mutations targeting admin-role users. + ## [v0.1.2] - 2026-05-29 - Enhance delete article logic to prevent multiple API calls once an article is deleted that returns 404. 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/repositories/auth.repository.ts b/apps/server/src/repositories/auth.repository.ts index 657ccdf..507d3a0 100644 --- a/apps/server/src/repositories/auth.repository.ts +++ b/apps/server/src/repositories/auth.repository.ts @@ -1,8 +1,8 @@ -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'; -import { usersTable } from '@/db/schemas/index.js'; +import { linksTable, usersTable } from '@/db/schemas/index.js'; import { logger } from '@/lib/logger.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,27 @@ 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; + countArticlesByUser(): 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 +147,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 +179,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 +247,28 @@ 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); + }, + + 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 new file mode 100644 index 0000000..03d977d --- /dev/null +++ b/apps/server/src/routes/admin/admin.handlers.ts @@ -0,0 +1,147 @@ +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, + SoftDeleteUserRoute, + UpdateUserRoute +} from './admin.routes.js'; + +export const listUsers: AppRouteHandler = async (c) => { + 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); + } +}; + +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 new file mode 100644 index 0000000..3a70f89 --- /dev/null +++ b/apps/server/src/routes/admin/admin.index.ts @@ -0,0 +1,15 @@ +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) + .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) + .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 new file mode 100644 index 0000000..bb0aee9 --- /dev/null +++ b/apps/server/src/routes/admin/admin.routes.ts @@ -0,0 +1,241 @@ +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.uuid(), + 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(), + articleCount: z.number().optional() +}); + +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'] + }); + +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', + path: '/admin/users', + middleware: [currentUser, adminUser], + request: { + query: listUsersQuerySchema + }, + 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 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 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/admin/admin.test.ts b/apps/server/src/routes/admin/admin.test.ts new file mode 100644 index 0000000..d39dec5 --- /dev/null +++ b/apps/server/src/routes/admin/admin.test.ts @@ -0,0 +1,340 @@ +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 { 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, (testApp) => { + testApp.use('*', async (c, next) => { + c.set('repos', repos); + return next(); + }); + }); + + 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 () => { + 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); + }); + + 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/auth/auth.handlers.ts b/apps/server/src/routes/auth/auth.handlers.ts index 669efe5..a549f1d 100644 --- a/apps/server/src/routes/auth/auth.handlers.ts +++ b/apps/server/src/routes/auth/auth.handlers.ts @@ -57,6 +57,7 @@ export const create: AppRouteHandler = async (c) => { const userCount = await auth.countUsers(); const isFirstUser = userCount === 0; + const role = isFirstUser ? 'admin' : 'user'; const createdUser = await auth.create({ email, @@ -83,6 +84,7 @@ export const create: AppRouteHandler = async (c) => { email: createdUser.email, name: createdUser.name, avatar: createdUser.avatar, + role, settings: createdUser.settings }, 'User created successfully', @@ -119,6 +121,7 @@ export const login: AppRouteHandler = async (c) => { email: user.email, name: user.name, avatar: user.avatar, + role: user.role, settings: user.settings }, 'User logged in successfully' @@ -153,6 +156,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..6cdc183 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', @@ -34,7 +36,7 @@ export const create = createRoute({ }, responses: { [HttpStatus.CREATED]: jsonContent( - successResponseSchema(selectUsersSchema, HttpStatus.CREATED), + successResponseSchema(currentUserSchema, HttpStatus.CREATED), 'User Registration Success' ), [HttpStatus.FORBIDDEN]: jsonContent( @@ -57,7 +59,7 @@ export const login = createRoute({ body: jsonContentRequired(z.object({ email: z.string(), password: z.string() }), '') }, responses: { - [HttpStatus.OK]: jsonContent(successResponseSchema(selectUsersSchema), 'Login Success'), + [HttpStatus.OK]: jsonContent(successResponseSchema(currentUserSchema), 'Login Success'), [HttpStatus.UNAUTHORIZED]: jsonContent( errorResponseSchema(HttpStatus.UNAUTHORIZED), 'Invalid credentials' @@ -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..fb8afda 100644 --- a/apps/server/src/routes/auth/auth.test.ts +++ b/apps/server/src/routes/auth/auth.test.ts @@ -119,6 +119,7 @@ describe('auth routes', () => { if (response.status === HttpStatus.CREATED) { const json = await response.json(); expect(json.result.email).toBe(TEST_EMAIL); + expect(json.result.role).toBe('admin'); expect(json.status).toBe(HttpStatus.CREATED); expect(extractTokenCookie(response)).not.toBe(''); } @@ -195,6 +196,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).toBe('admin'); expect(extractTokenCookie(response)).not.toBe(''); } }); @@ -224,6 +226,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/server/src/routes/files/files.test.ts b/apps/server/src/routes/files/files.test.ts index a1b0268..d69261a 100644 --- a/apps/server/src/routes/files/files.test.ts +++ b/apps/server/src/routes/files/files.test.ts @@ -85,11 +85,15 @@ 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, + 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 c4bb96b..962cec6 100644 --- a/apps/server/src/services/admin.service.ts +++ b/apps/server/src/services/admin.service.ts @@ -1,53 +1,36 @@ -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 - })); + 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 { @@ -58,18 +41,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 +86,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/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 7365ebb..4fd9723 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,74 @@ 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, + + countArticlesByUser: async () => ({}), + + 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; } 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..df30fc4 --- /dev/null +++ b/apps/web/src/components/layouts/admin.tsx @@ -0,0 +1,113 @@ +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' }, + { label: t('admin.nav.connections'), to: '/admin/connections' } + ] 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/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-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/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..5f9061b --- /dev/null +++ b/apps/web/src/features/admin/api/types.ts @@ -0,0 +1,21 @@ +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; + articleCount?: number; +}; + +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/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-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..e5f893c --- /dev/null +++ b/apps/web/src/features/admin/components/admin-user-list.tsx @@ -0,0 +1,115 @@ +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.articles')} + {t('admin.user.created')} + {t('admin.user.actions')} + + + + {users.map((user) => ( + + {user.name ?? t('admin.user.noName')} + {user.email} + + + + {user.articleCount ?? 0} + {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()} + {' · '} + {t('admin.user.articlesCount', { count: user.articleCount ?? 0 })} +
    +
    + +
    +
    + ))} +
    + + ); +} 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 }); 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..0dda665 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,11 +11,14 @@ 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 ProtectedAdminConnectionsRouteImport } from './routes/_protected/admin/connections' import { Route as ProtectedWithLayoutManageTagsRouteImport } from './routes/_protected/_with-layout/manage-tags' import { Route as ProtectedWithLayoutSettingsIndexRouteImport } from './routes/_protected/_with-layout/settings/index' import { Route as ProtectedWithLayoutArticlesIndexRouteImport } from './routes/_protected/_with-layout/articles/index' @@ -30,6 +33,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 +52,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: '/', @@ -55,6 +68,12 @@ const ProtectedArticlesIdRoute = ProtectedArticlesIdRouteImport.update({ path: '/articles/$id', getParentRoute: () => ProtectedRoute, } as any) +const ProtectedAdminConnectionsRoute = + ProtectedAdminConnectionsRouteImport.update({ + id: '/connections', + path: '/connections', + getParentRoute: () => ProtectedAdminRoute, + } as any) const ProtectedWithLayoutManageTagsRoute = ProtectedWithLayoutManageTagsRouteImport.update({ id: '/manage-tags', @@ -90,8 +109,11 @@ export interface FileRoutesByFullPath { '/': typeof ProtectedWithLayoutIndexRoute '/login': typeof AuthLoginRoute '/register': typeof AuthRegisterRoute + '/admin': typeof ProtectedAdminRouteWithChildren '/manage-tags': typeof ProtectedWithLayoutManageTagsRoute + '/admin/connections': typeof ProtectedAdminConnectionsRoute '/articles/$id': typeof ProtectedArticlesIdRoute + '/admin/': typeof ProtectedAdminIndexRoute '/articles/': typeof ProtectedWithLayoutArticlesIndexRoute '/settings/': typeof ProtectedWithLayoutSettingsIndexRoute '/settings/import-articles/$sessionId': typeof ProtectedWithLayoutSettingsImportArticlesSessionIdRoute @@ -102,7 +124,9 @@ export interface FileRoutesByTo { '/login': typeof AuthLoginRoute '/register': typeof AuthRegisterRoute '/manage-tags': typeof ProtectedWithLayoutManageTagsRoute + '/admin/connections': typeof ProtectedAdminConnectionsRoute '/articles/$id': typeof ProtectedArticlesIdRoute + '/admin': typeof ProtectedAdminIndexRoute '/articles': typeof ProtectedWithLayoutArticlesIndexRoute '/settings': typeof ProtectedWithLayoutSettingsIndexRoute '/settings/import-articles/$sessionId': typeof ProtectedWithLayoutSettingsImportArticlesSessionIdRoute @@ -115,9 +139,12 @@ 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/admin/connections': typeof ProtectedAdminConnectionsRoute '/_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 +156,11 @@ export interface FileRouteTypes { | '/' | '/login' | '/register' + | '/admin' | '/manage-tags' + | '/admin/connections' | '/articles/$id' + | '/admin/' | '/articles/' | '/settings/' | '/settings/import-articles/$sessionId' @@ -141,7 +171,9 @@ export interface FileRouteTypes { | '/login' | '/register' | '/manage-tags' + | '/admin/connections' | '/articles/$id' + | '/admin' | '/articles' | '/settings' | '/settings/import-articles/$sessionId' @@ -153,9 +185,12 @@ export interface FileRouteTypes { | '/_auth/login' | '/_auth/register' | '/_protected/_with-layout' + | '/_protected/admin' | '/_protected/_with-layout/manage-tags' + | '/_protected/admin/connections' | '/_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 +218,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 +246,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: '/' @@ -218,6 +267,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedArticlesIdRouteImport parentRoute: typeof ProtectedRoute } + '/_protected/admin/connections': { + id: '/_protected/admin/connections' + path: '/connections' + fullPath: '/admin/connections' + preLoaderRoute: typeof ProtectedAdminConnectionsRouteImport + parentRoute: typeof ProtectedAdminRoute + } '/_protected/_with-layout/manage-tags': { id: '/_protected/_with-layout/manage-tags' path: '/manage-tags' @@ -291,13 +347,29 @@ const ProtectedWithLayoutRouteChildren: ProtectedWithLayoutRouteChildren = { const ProtectedWithLayoutRouteWithChildren = ProtectedWithLayoutRoute._addFileChildren(ProtectedWithLayoutRouteChildren) +interface ProtectedAdminRouteChildren { + ProtectedAdminConnectionsRoute: typeof ProtectedAdminConnectionsRoute + ProtectedAdminIndexRoute: typeof ProtectedAdminIndexRoute +} + +const ProtectedAdminRouteChildren: ProtectedAdminRouteChildren = { + ProtectedAdminConnectionsRoute: ProtectedAdminConnectionsRoute, + 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/connections.tsx b/apps/web/src/routes/_protected/admin/connections.tsx new file mode 100644 index 0000000..ba29040 --- /dev/null +++ b/apps/web/src/routes/_protected/admin/connections.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/react-router'; + +import { AdminConnectionsPanel } from '@/features/admin/components/admin-connections-panel'; + +import i18n from '@/lib/i18n'; + +export const Route = createFileRoute('/_protected/admin/connections')({ + head: () => ({ meta: [{ title: i18n.t('routes.admin.metaTitle') }] }), + component: AdminConnectionsPanel +}); 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..4d90007 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,70 @@ "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.", + "articles": "Articles", + "articlesCount": "{{count}} articles" + }, + "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." + }, + "connections": { + "title": "Connections", + "description": "Status of external services this instance depends on.", + "refresh": "Refresh", + "error": "Couldn't load connection statuses.", + "autoRefresh": "Refreshes automatically every 30 seconds.", + "status": { + "ok": "Operational", + "degraded": "Degraded", + "down": "Down" + }, + "latency": "{{ms}} ms" + } } }