Add ROP-only user management UI

Schema:
- New app_users table with username, password_hash, role
- On boot, seed app_users from AUTH_USERS env if empty; subsequent
  reads always come from DB

Backend (auth refactor):
- initAuth() bootstraps and loads users into in-memory map
- listAllUsers / listManagers now read from DB-backed map
- createUser / updateUserPassword / updateUserRole / deleteUser helpers
  re-bcrypt and reload the map
- New admin routes (rop only):
  - GET /admin/users
  - POST /admin/users
  - PATCH /admin/users/:username (password and/or role)
  - DELETE /admin/users/:username (cannot delete self)
- All admin mutations write to audit_log

Frontend:
- api/admin.ts client for the four endpoints
- AdminUsersView: create form, table with inline role select,
  reset-password and delete actions, "это вы" marker
- Router: /admin/users with ropOnly meta
- App.vue header: Пользователи nav link for rop

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ask 2026-05-19 17:06:30 +05:00
parent 837f8e11bb
commit ba121a6bfc
8 changed files with 420 additions and 2 deletions

View File

@ -1,6 +1,7 @@
import bcrypt from 'bcrypt';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { config } from './config.js';
import { query } from './db.js';
import { AppError } from './errors.js';
import type { JwtUser } from './types.js';
@ -31,14 +32,47 @@ function parseAuthUsers(raw: string): Map<string, AuthUserEntry> {
return map;
}
const users = parseAuthUsers(config.authUsers);
let users = new Map<string, AuthUserEntry>();
async function seedFromEnv(): Promise<void> {
const env = parseAuthUsers(config.authUsers);
for (const [username, entry] of env) {
await query(
`INSERT INTO app_users (username, password_hash, role)
VALUES ($1, $2, $3)
ON CONFLICT (username) DO NOTHING`,
[username, entry.hash, entry.role],
);
}
}
export async function reloadUsers(): Promise<void> {
const { rows } = await query<{ username: string; password_hash: string; role: string }>(
`SELECT username, password_hash, role FROM app_users`,
);
const map = new Map<string, AuthUserEntry>();
for (const r of rows) map.set(r.username, { hash: r.password_hash, role: r.role });
users = map;
}
export async function initAuth(): Promise<void> {
const { rows } = await query<{ count: string }>(`SELECT COUNT(*)::text AS count FROM app_users`);
if (Number(rows[0]?.count ?? 0) === 0) await seedFromEnv();
await reloadUsers();
}
export function listManagers(): string[] {
const result: string[] = [];
for (const [username, entry] of users) {
if (entry.role === 'manager') result.push(username);
}
return result;
return result.sort();
}
export function listAllUsers(): { username: string; role: string }[] {
return [...users.entries()]
.map(([username, entry]) => ({ username, role: entry.role }))
.sort((a, b) => a.username.localeCompare(b.username));
}
export async function verifyCredentials(
@ -52,6 +86,51 @@ export async function verifyCredentials(
return { sub: username, role: entry.role };
}
export async function createUser(
username: string,
password: string,
role: 'manager' | 'rop',
): Promise<void> {
if (!username.trim() || !password.trim()) {
throw AppError.validation('username and password required');
}
const hash = await bcrypt.hash(password, 10);
await query(
`INSERT INTO app_users (username, password_hash, role) VALUES ($1, $2, $3)`,
[username.trim(), hash, role],
);
await reloadUsers();
}
export async function updateUserPassword(username: string, password: string): Promise<void> {
if (!password.trim()) throw AppError.validation('password required');
const hash = await bcrypt.hash(password, 10);
const { rowCount } = await query(
`UPDATE app_users SET password_hash = $1 WHERE username = $2`,
[hash, username],
);
if (!rowCount) throw AppError.notFound('User not found');
await reloadUsers();
}
export async function updateUserRole(
username: string,
role: 'manager' | 'rop',
): Promise<void> {
const { rowCount } = await query(
`UPDATE app_users SET role = $1 WHERE username = $2`,
[role, username],
);
if (!rowCount) throw AppError.notFound('User not found');
await reloadUsers();
}
export async function deleteUser(username: string): Promise<void> {
const { rowCount } = await query(`DELETE FROM app_users WHERE username = $1`, [username]);
if (!rowCount) throw AppError.notFound('User not found');
await reloadUsers();
}
export async function requireAuth(
app: FastifyInstance,
req: FastifyRequest,

View File

@ -0,0 +1,80 @@
import type { FastifyInstance } from 'fastify';
import {
createUser,
deleteUser,
listAllUsers,
requireAuth,
requireRole,
updateUserPassword,
updateUserRole,
} from '../auth.js';
import { audit } from '../audit.js';
import { AppError } from '../errors.js';
function parseRole(v: unknown): 'manager' | 'rop' {
if (v === 'manager' || v === 'rop') return v;
throw AppError.validation('role must be manager or rop', { role: 'invalid' });
}
export async function adminRoutes(app: FastifyInstance) {
app.get('/admin/users', async (req) => {
const user = await requireAuth(app, req);
requireRole(user, 'rop');
return { data: listAllUsers() };
});
app.post<{ Body: { username?: unknown; password?: unknown; role?: unknown } }>(
'/admin/users',
async (req) => {
const user = await requireAuth(app, req);
requireRole(user, 'rop');
const username = typeof req.body?.username === 'string' ? req.body.username.trim() : '';
const password = typeof req.body?.password === 'string' ? req.body.password : '';
const role = parseRole(req.body?.role);
if (!username) throw AppError.validation('username required', { username: 'required' });
if (!password) throw AppError.validation('password required', { password: 'required' });
try {
await createUser(username, password, role);
} catch (e) {
if ((e as { code?: string }).code === '23505') {
throw AppError.validation('username already exists', { username: 'taken' });
}
throw e;
}
await audit(user.sub, 'user', username, 'create', { role });
return { username, role };
},
);
app.patch<{
Params: { username: string };
Body: { password?: unknown; role?: unknown };
}>('/admin/users/:username', async (req) => {
const user = await requireAuth(app, req);
requireRole(user, 'rop');
const { username } = req.params;
if (req.body?.password !== undefined) {
const password = typeof req.body.password === 'string' ? req.body.password : '';
await updateUserPassword(username, password);
await audit(user.sub, 'user', username, 'reset_password');
}
if (req.body?.role !== undefined) {
const role = parseRole(req.body.role);
await updateUserRole(username, role);
await audit(user.sub, 'user', username, 'change_role', { role });
}
return { ok: true };
});
app.delete<{ Params: { username: string } }>('/admin/users/:username', async (req, reply) => {
const user = await requireAuth(app, req);
requireRole(user, 'rop');
if (req.params.username === user.sub) {
throw AppError.validation('cannot delete yourself');
}
await deleteUser(req.params.username);
await audit(user.sub, 'user', req.params.username, 'delete');
return reply.code(204).send();
});
}

View File

@ -1,6 +1,7 @@
import Fastify from 'fastify';
import jwt from '@fastify/jwt';
import { config } from './config.js';
import { initAuth } from './auth.js';
import { AppError } from './errors.js';
import { authRoutes } from './routes/auth.js';
import { leadRoutes } from './routes/leads.js';
@ -11,6 +12,7 @@ import { intakeRoutes } from './routes/intake.js';
import { noteRoutes } from './routes/notes.js';
import { taskRoutes } from './routes/tasks.js';
import { auditRoutes } from './routes/audit.js';
import { adminRoutes } from './routes/admin.js';
async function buildApp() {
const app = Fastify({
@ -18,6 +20,7 @@ async function buildApp() {
trustProxy: true,
});
await initAuth();
await app.register(jwt, { secret: config.jwtSecret });
app.setErrorHandler((err, req, reply) => {
@ -48,6 +51,7 @@ async function buildApp() {
await app.register(noteRoutes);
await app.register(taskRoutes);
await app.register(auditRoutes);
await app.register(adminRoutes);
return app;
}

View File

@ -50,6 +50,13 @@ function userInitials(name: string | undefined): string {
>
Статистика
</RouterLink>
<RouterLink
v-if="auth.isRop"
:to="{ name: 'admin-users' }"
class="nav-link"
>
Пользователи
</RouterLink>
</nav>
</div>
<div class="flex items-center gap-3">

View File

@ -0,0 +1,31 @@
import { apiFetch } from './client';
export interface AppUser {
username: string;
role: 'manager' | 'rop';
}
export function listUsers() {
return apiFetch<{ data: AppUser[] }>('/admin/users');
}
export function createUser(body: { username: string; password: string; role: 'manager' | 'rop' }) {
return apiFetch<AppUser>('/admin/users', {
method: 'POST',
body: JSON.stringify(body),
});
}
export function updateUser(
username: string,
body: { password?: string; role?: 'manager' | 'rop' },
) {
return apiFetch<{ ok: true }>(`/admin/users/${username}`, {
method: 'PATCH',
body: JSON.stringify(body),
});
}
export function deleteUser(username: string) {
return apiFetch<void>(`/admin/users/${username}`, { method: 'DELETE' });
}

View File

@ -39,6 +39,12 @@ const router = createRouter({
meta: { ropOnly: true },
props: true,
},
{
path: '/admin/users',
name: 'admin-users',
component: () => import('@/views/AdminUsersView.vue'),
meta: { ropOnly: true },
},
],
});

View File

@ -0,0 +1,204 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { useToast } from '@/composables/useToast';
import {
createUser,
deleteUser,
listUsers,
updateUser,
type AppUser,
} from '@/api/admin';
import Skeleton from '@/components/Skeleton.vue';
const auth = useAuthStore();
const toast = useToast();
const users = ref<AppUser[]>([]);
const loading = ref(false);
const form = reactive<{ username: string; password: string; role: 'manager' | 'rop' }>({
username: '',
password: '',
role: 'manager',
});
const creating = ref(false);
async function load() {
loading.value = true;
try {
const res = await listUsers();
users.value = res.data;
} catch (e) {
toast.error((e as Error).message);
} finally {
loading.value = false;
}
}
async function add() {
if (!form.username.trim() || !form.password.trim()) return;
creating.value = true;
try {
await createUser({
username: form.username.trim(),
password: form.password,
role: form.role,
});
toast.success(`Пользователь ${form.username} создан`);
form.username = '';
form.password = '';
form.role = 'manager';
await load();
} catch (e) {
toast.error((e as Error).message);
} finally {
creating.value = false;
}
}
async function resetPassword(u: AppUser) {
const pwd = prompt(`Новый пароль для ${u.username}:`);
if (!pwd) return;
try {
await updateUser(u.username, { password: pwd });
toast.success('Пароль обновлён');
} catch (e) {
toast.error((e as Error).message);
}
}
async function changeRole(u: AppUser, role: 'manager' | 'rop') {
if (role === u.role) return;
try {
await updateUser(u.username, { role });
u.role = role;
toast.success(`Роль ${u.username}: ${role}`);
} catch (e) {
toast.error((e as Error).message);
}
}
async function remove(u: AppUser) {
if (u.username === auth.user?.username) {
toast.error('Нельзя удалить себя');
return;
}
if (!confirm(`Удалить пользователя ${u.username}?`)) return;
try {
await deleteUser(u.username);
users.value = users.value.filter((x) => x.username !== u.username);
toast.success('Удалён');
} catch (e) {
toast.error((e as Error).message);
}
}
onMounted(load);
</script>
<template>
<div class="max-w-3xl mx-auto space-y-6">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Пользователи</h1>
<p class="text-sm text-gray-500 mt-1">
Управление учётками: менеджеры и РОП. Изменения применяются сразу.
</p>
</div>
<form class="card-pad space-y-3" @submit.prevent="add">
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">
Создать
</h2>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<input
v-model="form.username"
type="text"
required
placeholder="username"
class="input-base text-sm"
/>
<input
v-model="form.password"
type="text"
required
placeholder="пароль"
class="input-base text-sm"
/>
<select v-model="form.role" class="input-base text-sm">
<option value="manager">manager</option>
<option value="rop">rop</option>
</select>
<button
type="submit"
class="btn-primary text-sm"
:disabled="creating || !form.username.trim() || !form.password.trim()"
>
{{ creating ? '…' : 'Добавить' }}
</button>
</div>
</form>
<div v-if="loading" class="card p-4">
<Skeleton :rows="4" height="40px" />
</div>
<div v-else class="card overflow-hidden">
<table class="table-base">
<thead>
<tr>
<th>Username</th>
<th>Роль</th>
<th class="text-right">Действия</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.username">
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div
class="w-7 h-7 rounded-full bg-gray-100 text-gray-700 flex items-center justify-center text-xs font-semibold"
>
{{ u.username[0].toUpperCase() }}
</div>
<span class="font-medium">{{ u.username }}</span>
<span
v-if="u.username === auth.user?.username"
class="badge bg-sky-100 text-sky-700"
>
это вы
</span>
</div>
</td>
<td class="px-4 py-3">
<select
:value="u.role"
class="input-base !py-1 !px-2 w-auto text-xs"
:disabled="u.username === auth.user?.username"
@change="changeRole(u, ($event.target as HTMLSelectElement).value as 'manager' | 'rop')"
>
<option value="manager">manager</option>
<option value="rop">rop</option>
</select>
</td>
<td class="px-4 py-3 text-right">
<button
type="button"
class="btn-secondary !py-1 text-xs mr-2"
@click="resetPassword(u)"
>
Сменить пароль
</button>
<button
v-if="u.username !== auth.user?.username"
type="button"
class="btn-danger !py-1 text-xs"
@click="remove(u)"
>
Удалить
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View File

@ -0,0 +1,7 @@
-- Up Migration
CREATE TABLE app_users (
username TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('manager', 'rop')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);