Files
auth-portal/apps/api/src/routes/admin.ts
T
DenozordecandCursor 0fa4b7adea
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m49s
Build and Push Auth Portal Docker Image / create-release (push) Skipped
feat(app-switcher): централизовать ссылки приложений в portal settings
Публичный GET и admin PUT/UI /admin/apps; каталог и chrome читают URL из store.

Co-authored-by: Cursor <[email protected]>
2026-07-18 20:59:01 +07:00

226 lines
6.6 KiB
TypeScript

import type { FastifyInstance } from 'fastify'
import { hash } from '@node-rs/argon2'
import {
createUser,
deleteUser,
getAppSwitcherConfig,
getUserApps,
getUserByEmail,
getUserById,
getUserPermissions,
listUsers,
setAppSwitcherConfig,
setUserAccess,
updateUser,
} from '@authportal/db'
import {
APP_IDS,
allPermissionKeys,
appSwitcherConfigSchema,
createUserRequestSchema,
patchUserRequestSchema,
putUserAccessRequestSchema,
type AdminUser,
type AppId,
} from '@authportal/shared'
import { requireAdmin } from '../plugins/auth-guards.js'
const allowedPermissions = new Set(allPermissionKeys())
function mapUser(
db: FastifyInstance['db'],
user: NonNullable<ReturnType<typeof getUserById>>,
): AdminUser {
const apps = getUserApps(db, user.id).filter((a): a is AppId =>
(APP_IDS as readonly string[]).includes(a),
)
const permissions = getUserPermissions(db, user.id)
return {
id: user.id,
email: user.email,
name: user.name,
is_admin: user.isAdmin,
disabled: user.disabled,
apps,
permissions,
created_at: user.createdAt,
updated_at: user.updatedAt,
}
}
function validateAccess(apps: string[], permissions: string[]): string | null {
for (const app of apps) {
if (!(APP_IDS as readonly string[]).includes(app)) {
return `Неизвестное приложение: ${app}`
}
}
for (const p of permissions) {
if (!allowedPermissions.has(p)) {
return `Неизвестное право: ${p}`
}
}
return null
}
export async function adminRoutes(app: FastifyInstance): Promise<void> {
app.addHook('onRequest', async (request, reply) => {
if (!request.url.startsWith('/api/v1/admin')) return
await requireAdmin(request, reply)
})
app.get('/api/v1/admin/users', async () => {
return listUsers(app.db).map((u) => mapUser(app.db, u))
})
app.get<{ Params: { id: string } }>(
'/api/v1/admin/users/:id',
async (request, reply) => {
const user = getUserById(app.db, request.params.id)
if (!user) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
return mapUser(app.db, user)
},
)
app.post('/api/v1/admin/users', async (request, reply) => {
const parsed = createUserRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const data = parsed.data
if (getUserByEmail(app.db, data.email)) {
return reply.status(409).send({
error: { code: 'CONFLICT', message: 'Email уже занят' },
})
}
const accessError = validateAccess(data.apps, data.permissions)
if (accessError) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: accessError },
})
}
const passwordHash = await hash(data.password)
const user = createUser(app.db, {
email: data.email,
name: data.name,
passwordHash,
isAdmin: data.is_admin,
})
setUserAccess(app.db, user.id, data.apps, data.permissions)
return reply.status(201).send(mapUser(app.db, getUserById(app.db, user.id)!))
})
app.patch<{ Params: { id: string } }>(
'/api/v1/admin/users/:id',
async (request, reply) => {
const parsed = patchUserRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const existing = getUserById(app.db, request.params.id)
if (!existing) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
if (parsed.data.email && parsed.data.email !== existing.email) {
const clash = getUserByEmail(app.db, parsed.data.email)
if (clash) {
return reply.status(409).send({
error: { code: 'CONFLICT', message: 'Email уже занят' },
})
}
}
const passwordHash = parsed.data.password
? await hash(parsed.data.password)
: undefined
const updated = updateUser(app.db, request.params.id, {
email: parsed.data.email,
name: parsed.data.name,
passwordHash,
isAdmin: parsed.data.is_admin,
disabled: parsed.data.disabled,
})
return mapUser(app.db, updated!)
},
)
app.delete<{ Params: { id: string } }>(
'/api/v1/admin/users/:id',
async (request, reply) => {
if (request.authUser?.id === request.params.id) {
return reply.status(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Нельзя удалить себя',
},
})
}
const ok = deleteUser(app.db, request.params.id)
if (!ok) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
return reply.status(204).send()
},
)
app.put<{ Params: { id: string } }>(
'/api/v1/admin/users/:id/access',
async (request, reply) => {
const parsed = putUserAccessRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const existing = getUserById(app.db, request.params.id)
if (!existing) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
const accessError = validateAccess(
parsed.data.apps,
parsed.data.permissions,
)
if (accessError) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: accessError },
})
}
setUserAccess(
app.db,
request.params.id,
parsed.data.apps,
parsed.data.permissions,
)
return mapUser(app.db, getUserById(app.db, request.params.id)!)
},
)
app.get('/api/v1/admin/app-switcher', async () =>
getAppSwitcherConfig(app.db),
)
app.put('/api/v1/admin/app-switcher', async (request, reply) => {
const parsed = appSwitcherConfigSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
return setAppSwitcherConfig(app.db, parsed.data)
})
}