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:
parent
837f8e11bb
commit
ba121a6bfc
@ -1,6 +1,7 @@
|
|||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
|
import { query } from './db.js';
|
||||||
import { AppError } from './errors.js';
|
import { AppError } from './errors.js';
|
||||||
import type { JwtUser } from './types.js';
|
import type { JwtUser } from './types.js';
|
||||||
|
|
||||||
@ -31,14 +32,47 @@ function parseAuthUsers(raw: string): Map<string, AuthUserEntry> {
|
|||||||
return map;
|
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[] {
|
export function listManagers(): string[] {
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
for (const [username, entry] of users) {
|
for (const [username, entry] of users) {
|
||||||
if (entry.role === 'manager') result.push(username);
|
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(
|
export async function verifyCredentials(
|
||||||
@ -52,6 +86,51 @@ export async function verifyCredentials(
|
|||||||
return { sub: username, role: entry.role };
|
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(
|
export async function requireAuth(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
req: FastifyRequest,
|
req: FastifyRequest,
|
||||||
|
|||||||
80
leadscore/backend/src/routes/admin.ts
Normal file
80
leadscore/backend/src/routes/admin.ts
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import jwt from '@fastify/jwt';
|
import jwt from '@fastify/jwt';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
|
import { initAuth } from './auth.js';
|
||||||
import { AppError } from './errors.js';
|
import { AppError } from './errors.js';
|
||||||
import { authRoutes } from './routes/auth.js';
|
import { authRoutes } from './routes/auth.js';
|
||||||
import { leadRoutes } from './routes/leads.js';
|
import { leadRoutes } from './routes/leads.js';
|
||||||
@ -11,6 +12,7 @@ import { intakeRoutes } from './routes/intake.js';
|
|||||||
import { noteRoutes } from './routes/notes.js';
|
import { noteRoutes } from './routes/notes.js';
|
||||||
import { taskRoutes } from './routes/tasks.js';
|
import { taskRoutes } from './routes/tasks.js';
|
||||||
import { auditRoutes } from './routes/audit.js';
|
import { auditRoutes } from './routes/audit.js';
|
||||||
|
import { adminRoutes } from './routes/admin.js';
|
||||||
|
|
||||||
async function buildApp() {
|
async function buildApp() {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
@ -18,6 +20,7 @@ async function buildApp() {
|
|||||||
trustProxy: true,
|
trustProxy: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await initAuth();
|
||||||
await app.register(jwt, { secret: config.jwtSecret });
|
await app.register(jwt, { secret: config.jwtSecret });
|
||||||
|
|
||||||
app.setErrorHandler((err, req, reply) => {
|
app.setErrorHandler((err, req, reply) => {
|
||||||
@ -48,6 +51,7 @@ async function buildApp() {
|
|||||||
await app.register(noteRoutes);
|
await app.register(noteRoutes);
|
||||||
await app.register(taskRoutes);
|
await app.register(taskRoutes);
|
||||||
await app.register(auditRoutes);
|
await app.register(auditRoutes);
|
||||||
|
await app.register(adminRoutes);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,6 +50,13 @@ function userInitials(name: string | undefined): string {
|
|||||||
>
|
>
|
||||||
Статистика
|
Статистика
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
v-if="auth.isRop"
|
||||||
|
:to="{ name: 'admin-users' }"
|
||||||
|
class="nav-link"
|
||||||
|
>
|
||||||
|
Пользователи
|
||||||
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
|||||||
31
leadscore/frontend/src/api/admin.ts
Normal file
31
leadscore/frontend/src/api/admin.ts
Normal 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' });
|
||||||
|
}
|
||||||
@ -39,6 +39,12 @@ const router = createRouter({
|
|||||||
meta: { ropOnly: true },
|
meta: { ropOnly: true },
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/users',
|
||||||
|
name: 'admin-users',
|
||||||
|
component: () => import('@/views/AdminUsersView.vue'),
|
||||||
|
meta: { ropOnly: true },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
204
leadscore/frontend/src/views/AdminUsersView.vue
Normal file
204
leadscore/frontend/src/views/AdminUsersView.vue
Normal 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>
|
||||||
7
leadscore/migrations/1700000006000_app_users.sql
Normal file
7
leadscore/migrations/1700000006000_app_users.sql
Normal 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()
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user