Skip to content
Merged
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -38,6 +39,7 @@ configureOpenAPI(app);

const router = app
.route('/', health)
.route('/', admin)
.route('/', auth)
.route('/', home)
.route('/', links)
Expand Down
74 changes: 72 additions & 2 deletions apps/server/src/repositories/auth.repository.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -16,6 +16,14 @@ import type {

type DrizzleClient = NodePgDatabase<typeof schema>;

export type ListUsersStatus = 'active' | 'deleted' | 'all';

export interface ListUsersOptions {
limit?: number;
offset?: number;
status?: ListUsersStatus;
}

const publicUserColumns = {
id: usersTable.id,
email: usersTable.email,
Expand Down Expand Up @@ -43,14 +51,27 @@ export interface AuthRepository {
// Returns full User including passwordHash — only for auth verification
findByIdWithCredentials(id: string): Promise<User | null>;
findByIdIncludingDeleted(id: string): Promise<UserWithoutPassword | null>;
listUsers(options?: ListUsersOptions): Promise<UserWithoutPassword[]>;
update(
id: string,
updates: Partial<Omit<User, 'passwordHash' | 'id' | 'createdAt'>>
): Promise<PublicUser | null>;
updateUserForAdmin(
id: string,
updates: Partial<Pick<User, 'name' | 'role'>>
): Promise<UserWithoutPassword | null>;
updateRole(id: string, role: string): Promise<PublicUserWithRole>;
updatePassword(id: string, passwordHash: string): Promise<PublicUser | null>;
updateDeletedAt(id: string, deletedAt: string | null): Promise<UserWithoutPassword>;
countUsers(): Promise<number>;
countActiveAdmins(): Promise<number>;
countArticlesByUser(): Promise<Record<string, number>>;
}

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 {
Expand Down Expand Up @@ -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<string, unknown>
}));
},

async update(id, updates) {
const [updatedUser] = await db
.update(usersTable)
Expand All @@ -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)
Expand Down Expand Up @@ -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<string, number> = {};
for (const row of rows) {
counts[row.userId] = Number(row.count);
}
return counts;
}
};
}
147 changes: 147 additions & 0 deletions apps/server/src/routes/admin/admin.handlers.ts
Original file line number Diff line number Diff line change
@@ -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<ListUsersRoute> = 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<GetUserRoute> = 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<UpdateUserRoute> = 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<ResetPasswordRoute> = 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<SoftDeleteUserRoute> = 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<RestoreUserRoute> = 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<ListConnectionsRoute> = async (c) => {
const connections = await adminHealthService.checkConnections();
const response = successResponse(connections, 'Service connections fetched successfully');
return c.json(response, response.status);
};
15 changes: 15 additions & 0 deletions apps/server/src/routes/admin/admin.index.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading