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:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 : "Ошибка входа";
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user