feat(api): user management and audit log for admins (M1)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 38s
ci / api / api (push) Successful in 18s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 35s

- /admin/users: list with search, role filter and cursor pages; create
  with a one-time temporary password; rename, change role, block;
  reset password; delete
- every change is checked against the role policy, a forbidden field
  rejects the whole patch; the last active admin is locked in the
  transaction and cannot be demoted, blocked or deleted
- role change and blocking revoke access tokens at once; blocking and
  password reset also end all sessions
- /admin/audit: newest-first journal with actor names
- path and query parameter errors are problem+json now

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 06:18:02 +03:00
1 parent ed4a492dba
commit f075863f15
13 files changed
+2578 -70

No files matched your search

+305 -1
View File
@@ -234,6 +234,94 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/admin/users": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Пользователи (admin, moderator)
* @description Сортировка по логину. `q` ищет по логину и имени без учёта регистра.
*/
get: operations["listUsers"];
put?: never;
/**
* Создать пользователя
* @description Пароль генерирует сервер и возвращает **один раз**; при первом входе его нужно сменить.
* Модератор создаёт только `user`, админ — любые роли.
*/
post: operations["createUser"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/users/{id}": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["UserId"];
};
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Удалить пользователя (admin) */
delete: operations["deleteUser"];
options?: never;
head?: never;
/**
* Изменить имя, роль, блокировку
* @description Роль меняет только админ. Смена роли и блокировка мгновенно отзывают токены пользователя.
* Последнего активного админа нельзя понизить или заблокировать — `409 users.last_admin`.
*/
patch: operations["updateUser"];
trace?: never;
};
"/api/v1/admin/users/{id}/reset-password": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["UserId"];
};
cookie?: never;
};
get?: never;
put?: never;
/**
* Сбросить пароль
* @description Новый временный пароль возвращается один раз; все сессии пользователя завершаются.
*/
post: operations["resetUserPassword"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/audit": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Журнал действий (admin), новые сверху */
get: operations["listAudit"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -340,6 +428,69 @@ export interface components {
new_username: string;
new_password: string;
};
AdminUser: {
/** Format: uuid */
id: string;
username: string;
display_name: string | null;
role: components["schemas"]["Role"];
locale: string;
is_blocked: boolean;
must_change_password: boolean;
must_change_credentials: boolean;
/** Format: date-time */
created_at: string;
/** Format: date-time */
last_login_at: string | null;
};
AdminUserPage: {
items: components["schemas"]["AdminUser"][];
next_cursor: string | null;
};
CreateUserRequest: {
username: string;
display_name?: string;
role: components["schemas"]["Role"];
};
CreatedUser: {
user: components["schemas"]["AdminUser"];
/** @description Показывается один раз */
temporary_password: string;
};
UpdateUserRequest: {
/** @description Пустая строка — сбросить имя */
display_name?: string;
role?: components["schemas"]["Role"];
is_blocked?: boolean;
};
TemporaryPassword: {
temporary_password: string;
};
AuditActor: {
/** Format: uuid */
id: string;
username: string;
};
AuditEntry: {
/** Format: uuid */
id: string;
/** @description null — система или удалённый пользователь */
actor: components["schemas"]["AuditActor"] | null;
/** @example user.create */
action: string;
target_type: string | null;
/** Format: uuid */
target_id: string | null;
payload: {
[key: string]: unknown;
} | null;
/** Format: date-time */
created_at: string;
};
AuditPage: {
items: components["schemas"]["AuditEntry"][];
next_cursor: string | null;
};
Session: {
/** Format: uuid */
id: string;
@@ -388,7 +539,12 @@ export interface components {
};
};
};
parameters: never;
parameters: {
Limit: number;
/** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */
Cursor: string;
UserId: string;
};
requestBodies: never;
headers: never;
pathItems: never;
@@ -693,4 +849,152 @@ export interface operations {
default: components["responses"]["Problem"];
};
};
listUsers: {
parameters: {
query?: {
q?: string;
role?: components["schemas"]["Role"];
limit?: components["parameters"]["Limit"];
/** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */
cursor?: components["parameters"]["Cursor"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Страница пользователей */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AdminUserPage"];
};
};
default: components["responses"]["Problem"];
};
};
createUser: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateUserRequest"];
};
};
responses: {
/** @description Пользователь создан */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CreatedUser"];
};
};
default: components["responses"]["Problem"];
};
};
deleteUser: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["UserId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Удалён */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
updateUser: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["UserId"];
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateUserRequest"];
};
};
responses: {
/** @description Обновлённый пользователь */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AdminUser"];
};
};
default: components["responses"]["Problem"];
};
};
resetUserPassword: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["UserId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Временный пароль */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TemporaryPassword"];
};
};
default: components["responses"]["Problem"];
};
};
listAudit: {
parameters: {
query?: {
limit?: components["parameters"]["Limit"];
/** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */
cursor?: components["parameters"]["Cursor"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Страница журнала */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AuditPage"];
};
};
default: components["responses"]["Problem"];
};
};
}
+9 -2
View File
@@ -21,7 +21,8 @@
"current_password_required": "Enter your current password",
"current_password_invalid": "The current password is wrong",
"bootstrap_not_required": "The administrator credentials have already been changed",
"session_not_found": "Session not found"
"session_not_found": "Session not found",
"forbidden": "You don't have permission to do this"
},
"validation": {
"password_too_short": "The password must be at least {{min}} characters",
@@ -34,6 +35,12 @@
"locale_unsupported": "This language is not supported",
"display_name_too_long": "The name must be at most {{max}} characters",
"ui_prefs_invalid": "Invalid setting value",
"client_type_invalid": "Unknown app type"
"client_type_invalid": "Unknown app type",
"role_invalid": "Unknown role",
"cursor_invalid": "The list is outdated, please reload"
},
"users": {
"not_found": "User not found",
"last_admin": "The last administrator cannot be demoted, blocked or deleted"
}
}
+9 -2
View File
@@ -21,7 +21,8 @@
"current_password_required": "Введите текущий пароль",
"current_password_invalid": "Текущий пароль указан неверно",
"bootstrap_not_required": "Учётные данные администратора уже изменены",
"session_not_found": "Сессия не найдена"
"session_not_found": "Сессия не найдена",
"forbidden": "Недостаточно прав для этого действия"
},
"validation": {
"password_too_short": "Пароль должен быть не короче {{min}} символов",
@@ -34,6 +35,12 @@
"locale_unsupported": "Этот язык не поддерживается",
"display_name_too_long": "Имя должно быть не длиннее {{max}} символов",
"ui_prefs_invalid": "Недопустимое значение настройки",
"client_type_invalid": "Неизвестный тип приложения"
"client_type_invalid": "Неизвестный тип приложения",
"role_invalid": "Неизвестная роль",
"cursor_invalid": "Список устарел, обновите страницу"
},
"users": {
"not_found": "Пользователь не найден",
"last_admin": "Нельзя понизить, заблокировать или удалить последнего администратора"
}
}