feat: multi-user UI login with admin and monitor roles

Add sac_users table, JWT roles, settings/users API guarded for admin, Users page in UI, and sac_manage_user.py CLI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-01 09:27:14 +10:00
parent bc77f82307
commit 56c469ddbd
20 changed files with 859 additions and 85 deletions
+8 -1
View File
@@ -8,11 +8,18 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import { computed, onMounted } from "vue";
import { useRoute } from "vue-router";
import { getToken } from "./api";
import AppSidebar from "./components/AppSidebar.vue";
import { refreshSessionRole } from "./router";
const route = useRoute();
const showShell = computed(() => route.path !== "/login" && !!getToken());
onMounted(() => {
if (getToken()) {
void refreshSessionRole();
}
});
</script>
+33
View File
@@ -1,15 +1,34 @@
const TOKEN_KEY = "sac_token";
const ROLE_KEY = "sac_role";
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function getRole(): string | null {
return localStorage.getItem(ROLE_KEY);
}
export function isAdmin(): boolean {
return getRole() === "admin";
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function setRole(role: string): void {
localStorage.setItem(ROLE_KEY, role);
}
export function setAuth(token: string, role: string): void {
setToken(token);
setRole(role);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(ROLE_KEY);
}
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
@@ -27,6 +46,9 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
window.location.href = "/login";
throw new Error("Unauthorized");
}
if (res.status === 403) {
throw new Error("Forbidden");
}
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
@@ -37,6 +59,17 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
export interface TokenResponse {
access_token: string;
token_type: string;
username: string;
role: string;
}
export interface MeResponse {
username: string;
role: string;
}
export function fetchMe(): Promise<MeResponse> {
return apiFetch<MeResponse>("/api/v1/auth/me");
}
export interface EventSummary {
+19 -10
View File
@@ -29,8 +29,9 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { clearToken } from "../api";
import { clearToken, isAdmin } from "../api";
import { APP_NAME, APP_VERSION } from "../version";
const appName = APP_NAME;
@@ -38,16 +39,24 @@ const appVersion = APP_VERSION;
const route = useRoute();
const router = useRouter();
const navItems = [
{ to: "/dashboard", label: "Обзор", icon: "▦", match: "exact" as const },
{ to: "/events", label: "События", icon: "", match: "prefix" as const },
{ to: "/reports", label: "Отчёты", icon: "", match: "prefix" as const },
{ to: "/problems", label: "Проблемы", icon: "!", match: "prefix" as const },
{ to: "/hosts", label: "Хосты", icon: "", match: "exact" as const },
{ to: "/settings", label: "Настройки", icon: "", match: "exact" as const },
];
const navItems = computed(() => {
const items = [
{ to: "/dashboard", label: "Обзор", icon: "", match: "exact" as const },
{ to: "/events", label: "События", icon: "", match: "prefix" as const },
{ to: "/reports", label: "Отчёты", icon: "", match: "prefix" as const },
{ to: "/problems", label: "Проблемы", icon: "!", match: "prefix" as const },
{ to: "/hosts", label: "Хосты", icon: "", match: "exact" as const },
];
if (isAdmin()) {
items.push(
{ to: "/settings", label: "Настройки", icon: "⚙", match: "exact" as const },
{ to: "/users", label: "Пользователи", icon: "👤", match: "exact" as const },
);
}
return items;
});
function isActive(item: (typeof navItems)[number]) {
function isActive(item: (typeof navItems.value)[number]) {
if (item.match === "exact") {
return route.path === item.to;
}
+18 -3
View File
@@ -1,5 +1,5 @@
import { createRouter, createWebHistory } from "vue-router";
import { getToken } from "./api";
import { getToken, isAdmin, setRole, fetchMe } from "./api";
import EventsView from "./views/EventsView.vue";
import EventDetailView from "./views/EventDetailView.vue";
import HostsView from "./views/HostsView.vue";
@@ -9,6 +9,7 @@ import ProblemDetailView from "./views/ProblemDetailView.vue";
import DashboardView from "./views/DashboardView.vue";
import ReportsView from "./views/ReportsView.vue";
import SettingsView from "./views/SettingsView.vue";
import UsersView from "./views/UsersView.vue";
const router = createRouter({
history: createWebHistory(),
@@ -22,14 +23,28 @@ const router = createRouter({
{ path: "/hosts", component: HostsView },
{ path: "/problems", component: ProblemsView },
{ path: "/problems/:id", component: ProblemDetailView, props: true },
{ path: "/settings", component: SettingsView },
{ path: "/settings", component: SettingsView, meta: { adminOnly: true } },
{ path: "/users", component: UsersView, meta: { adminOnly: true } },
],
});
router.beforeEach((to) => {
router.beforeEach(async (to) => {
if (to.path === "/login") return true;
if (!getToken()) return { path: "/login" };
if (to.meta.adminOnly && !isAdmin()) {
return { path: "/dashboard" };
}
return true;
});
export default router;
export async function refreshSessionRole(): Promise<void> {
if (!getToken()) return;
try {
const me = await fetchMe();
setRole(me.role);
} catch {
/* 401 handled in apiFetch */
}
}
+2 -2
View File
@@ -25,7 +25,7 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { apiFetch, setToken, type TokenResponse } from "../api";
import { apiFetch, setAuth, type TokenResponse } from "../api";
const router = useRouter();
const username = ref("admin");
@@ -41,7 +41,7 @@ async function submit() {
method: "POST",
body: JSON.stringify({ username: username.value, password: password.value }),
});
setToken(data.access_token);
setAuth(data.access_token, data.role);
await router.push("/events");
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка входа";
+181
View File
@@ -0,0 +1,181 @@
<template>
<div class="users-page">
<h1>Пользователи</h1>
<p class="users-intro">
Учётные записи входа в веб-панель SAC. Роль <strong>monitor</strong> просмотр без раздела «Настройки».
</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="success" class="success">{{ success }}</p>
<section class="card users-card">
<h2>Новый пользователь</h2>
<form class="users-form" @submit.prevent="createUser">
<label>
Логин
<input v-model="form.username" required minlength="2" autocomplete="off" />
</label>
<label>
Пароль
<input v-model="form.password" type="password" required minlength="8" autocomplete="new-password" />
</label>
<label>
Роль
<select v-model="form.role">
<option value="monitor">monitor просмотр</option>
<option value="admin">admin полный доступ</option>
</select>
</label>
<button type="submit" :disabled="creating">{{ creating ? "Создание…" : "Создать" }}</button>
</form>
</section>
<section class="card users-card">
<h2>Список</h2>
<p v-if="loading">Загрузка</p>
<table v-else class="users-table">
<thead>
<tr>
<th>Логин</th>
<th>Роль</th>
<th>Статус</th>
<th>Создан</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td>{{ u.username }}</td>
<td><code>{{ u.role }}</code></td>
<td>{{ u.is_active ? "активен" : "отключён" }}</td>
<td>{{ formatDate(u.created_at) }}</td>
</tr>
</tbody>
</table>
</section>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { apiFetch } from "../api";
interface UserSummary {
id: number;
username: string;
role: string;
is_active: boolean;
created_at: string;
}
interface UserListResponse {
items: UserSummary[];
}
const users = ref<UserSummary[]>([]);
const loading = ref(true);
const creating = ref(false);
const error = ref("");
const success = ref("");
const form = reactive({
username: "",
password: "",
role: "monitor",
});
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
async function loadUsers() {
loading.value = true;
error.value = "";
try {
const data = await apiFetch<UserListResponse>("/api/v1/users");
users.value = data.items;
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
} finally {
loading.value = false;
}
}
async function createUser() {
creating.value = true;
error.value = "";
success.value = "";
try {
await apiFetch<UserSummary>("/api/v1/users", {
method: "POST",
body: JSON.stringify({
username: form.username.trim(),
password: form.password,
role: form.role,
}),
});
success.value = `Пользователь ${form.username} создан`;
form.username = "";
form.password = "";
form.role = "monitor";
await loadUsers();
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка создания";
} finally {
creating.value = false;
}
}
onMounted(loadUsers);
</script>
<style scoped>
.users-page {
max-width: 52rem;
}
.users-intro {
color: #9aa4b2;
margin-bottom: 1rem;
}
.users-card {
margin-bottom: 1.25rem;
}
.users-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 24rem;
}
.users-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.users-table {
width: 100%;
border-collapse: collapse;
}
.users-table th,
.users-table td {
text-align: left;
padding: 0.45rem 0.5rem;
border-bottom: 1px solid #2a3441;
}
.error {
color: #ff8a8a;
}
.success {
color: #7dcea0;
}
</style>