feat: SAC 0.6.0 user edit UI, login to dashboard, daily report timer sync

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-01 09:55:12 +10:00
parent 56c469ddbd
commit 8de5d14cfb
17 changed files with 449 additions and 56 deletions
+56 -1
View File
@@ -51,7 +51,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
}
if (!res.ok) {
const text = await res.text();
throw new Error(text || res.statusText);
throw new Error(parseApiError(text) || res.statusText);
}
return res.json() as Promise<T>;
}
@@ -72,6 +72,61 @@ export function fetchMe(): Promise<MeResponse> {
return apiFetch<MeResponse>("/api/v1/auth/me");
}
export function parseApiError(text: string): string {
if (!text) return "";
try {
const data = JSON.parse(text) as { detail?: string | Array<{ msg?: string }> };
if (typeof data.detail === "string") return data.detail;
if (Array.isArray(data.detail)) {
return data.detail.map((item) => item.msg ?? String(item)).join("; ");
}
} catch {
/* plain text */
}
return text;
}
export interface UserSummary {
id: number;
username: string;
role: string;
is_active: boolean;
created_at: string;
}
export interface UserListResponse {
items: UserSummary[];
}
export interface UpdateUserPayload {
username?: string;
role?: string;
password?: string;
is_active?: boolean;
}
export function fetchUsers(): Promise<UserListResponse> {
return apiFetch<UserListResponse>("/api/v1/users");
}
export function createUser(payload: {
username: string;
password: string;
role: string;
}): Promise<UserSummary> {
return apiFetch<UserSummary>("/api/v1/users", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function updateUser(userId: number, payload: UpdateUserPayload): Promise<UserSummary> {
return apiFetch<UserSummary>(`/api/v1/users/${userId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export interface EventSummary {
id: number;
event_id: string;
+1 -1
View File
@@ -1,3 +1,3 @@
export const APP_NAME = "Security Alert Center";
export const APP_VERSION = "0.5.0";
export const APP_VERSION = "0.6.0";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+45 -5
View File
@@ -84,8 +84,47 @@ type SortKey =
| "last_seen_at"
| "event_count"
| "last_daily_report_at";
const sortBy = ref<SortKey>("last_seen_at");
const sortDir = ref<"asc" | "desc">("desc");
const HOSTS_SORT_KEYS: SortKey[] = [
"id",
"display_name",
"hostname",
"product",
"os_family",
"ipv4",
"last_seen_at",
"event_count",
"last_daily_report_at",
];
const HOSTS_SORT_STORAGE_KEY = "sac_hosts_sort";
function loadHostsSortPrefs(): { sortBy: SortKey; sortDir: "asc" | "desc" } {
const fallback = { sortBy: "last_seen_at" as SortKey, sortDir: "desc" as const };
try {
const raw = localStorage.getItem(HOSTS_SORT_STORAGE_KEY);
if (!raw) return fallback;
const parsed = JSON.parse(raw) as { sortBy?: string; sortDir?: string };
if (
parsed.sortBy &&
HOSTS_SORT_KEYS.includes(parsed.sortBy as SortKey) &&
(parsed.sortDir === "asc" || parsed.sortDir === "desc")
) {
return { sortBy: parsed.sortBy as SortKey, sortDir: parsed.sortDir };
}
} catch {
/* ignore corrupt storage */
}
return fallback;
}
function saveHostsSortPrefs(sortBy: SortKey, sortDir: "asc" | "desc") {
localStorage.setItem(HOSTS_SORT_STORAGE_KEY, JSON.stringify({ sortBy, sortDir }));
}
const initialSort = loadHostsSortPrefs();
const sortBy = ref<SortKey>(initialSort.sortBy);
const sortDir = ref<"asc" | "desc">(initialSort.sortDir);
const deletingId = ref<number | null>(null);
function formatDt(iso: string) {
@@ -101,10 +140,11 @@ function agentLabel(status: string) {
function setSort(key: SortKey) {
if (sortBy.value === key) {
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
return;
} else {
sortBy.value = key;
sortDir.value = key === "id" || key === "last_seen_at" || key === "event_count" ? "desc" : "asc";
}
sortBy.value = key;
sortDir.value = key === "id" || key === "last_seen_at" || key === "event_count" ? "desc" : "asc";
saveHostsSortPrefs(sortBy.value, sortDir.value);
}
function sortMark(key: SortKey) {
+1 -1
View File
@@ -42,7 +42,7 @@ async function submit() {
body: JSON.stringify({ username: username.value, password: password.value }),
});
setAuth(data.access_token, data.role);
await router.push("/events");
await router.push("/dashboard");
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка входа";
} finally {
+177 -38
View File
@@ -8,16 +8,23 @@
<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">
<section class="card users-card" :class="{ 'users-card-editing': editingUser }">
<h2>{{ editingUser ? `Редактирование: ${editingUser.username}` : "Новый пользователь" }}</h2>
<form class="users-form" @submit.prevent="submitForm">
<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" />
{{ editingUser ? "Новый пароль" : "Пароль" }}
<input
v-model="form.password"
type="password"
:required="!editingUser"
minlength="8"
autocomplete="new-password"
:placeholder="editingUser ? 'оставьте пустым, чтобы не менять' : ''"
/>
</label>
<label>
Роль
@@ -26,7 +33,24 @@
<option value="admin">admin полный доступ</option>
</select>
</label>
<button type="submit" :disabled="creating">{{ creating ? "Создание…" : "Создать" }}</button>
<label v-if="editingUser" class="users-inline">
<input v-model="form.is_active" type="checkbox" />
Активен (может входить)
</label>
<div class="users-actions">
<button type="submit" :disabled="submitting">
{{ submitting ? "Сохранение…" : editingUser ? "Сохранить изменения" : "Создать" }}
</button>
<button
v-if="editingUser"
type="button"
class="secondary"
:disabled="submitting"
@click="cancelEdit"
>
Отмена
</button>
</div>
</form>
</section>
@@ -40,14 +64,18 @@
<th>Роль</th>
<th>Статус</th>
<th>Создан</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<tr v-for="u in users" :key="u.id" :class="{ 'users-row-editing': editingUser?.id === u.id }">
<td>{{ u.username }}</td>
<td><code>{{ u.role }}</code></td>
<td>{{ u.is_active ? "активен" : "отключён" }}</td>
<td>{{ formatDate(u.created_at) }}</td>
<td>
<button type="button" class="secondary users-edit-btn" @click="startEdit(u)">Изменить</button>
</td>
</tr>
</tbody>
</table>
@@ -56,31 +84,26 @@
</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[];
}
import { nextTick, onMounted, reactive, ref } from "vue";
import {
createUser as apiCreateUser,
fetchUsers,
updateUser,
type UserSummary,
} from "../api";
const users = ref<UserSummary[]>([]);
const loading = ref(true);
const creating = ref(false);
const submitting = ref(false);
const error = ref("");
const success = ref("");
const editingUser = ref<UserSummary | null>(null);
const form = reactive({
username: "",
password: "",
role: "monitor",
is_active: true,
});
function formatDate(iso: string): string {
@@ -91,12 +114,32 @@ function formatDate(iso: string): string {
}
}
function clearMessages() {
error.value = "";
success.value = "";
}
function resetCreateForm() {
form.username = "";
form.password = "";
form.role = "monitor";
form.is_active = true;
}
async function loadUsers() {
loading.value = true;
error.value = "";
try {
const data = await apiFetch<UserListResponse>("/api/v1/users");
const data = await fetchUsers();
users.value = data.items;
if (editingUser.value) {
const fresh = data.items.find((u) => u.id === editingUser.value?.id);
if (fresh) {
editingUser.value = fresh;
fillEditForm(fresh);
} else {
cancelEdit();
}
}
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
} finally {
@@ -104,28 +147,99 @@ async function loadUsers() {
}
}
function fillEditForm(u: UserSummary) {
form.username = u.username;
form.password = "";
form.role = u.role;
form.is_active = u.is_active;
}
async function startEdit(u: UserSummary) {
clearMessages();
editingUser.value = u;
fillEditForm(u);
await nextTick();
document.querySelector(".users-card-editing")?.scrollIntoView({ behavior: "smooth", block: "start" });
}
function cancelEdit() {
editingUser.value = null;
resetCreateForm();
clearMessages();
}
async function submitForm() {
if (editingUser.value) {
await saveEdit();
} else {
await createUser();
}
}
async function createUser() {
creating.value = true;
error.value = "";
success.value = "";
submitting.value = true;
clearMessages();
try {
await apiFetch<UserSummary>("/api/v1/users", {
method: "POST",
body: JSON.stringify({
username: form.username.trim(),
password: form.password,
role: form.role,
}),
await apiCreateUser({
username: form.username.trim(),
password: form.password,
role: form.role,
});
success.value = `Пользователь ${form.username} создан`;
form.username = "";
form.password = "";
form.role = "monitor";
resetCreateForm();
await loadUsers();
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка создания";
} finally {
creating.value = false;
submitting.value = false;
}
}
async function saveEdit() {
if (!editingUser.value) return;
const payload: {
username?: string;
role?: string;
password?: string;
is_active?: boolean;
} = {};
const trimmedUsername = form.username.trim();
if (trimmedUsername !== editingUser.value.username) {
payload.username = trimmedUsername;
}
if (form.role !== editingUser.value.role) {
payload.role = form.role;
}
if (form.is_active !== editingUser.value.is_active) {
payload.is_active = form.is_active;
}
if (form.password.trim()) {
if (form.password.length < 8) {
error.value = "Пароль должен быть не короче 8 символов";
return;
}
payload.password = form.password;
}
if (Object.keys(payload).length === 0) {
error.value = "Нет изменений для сохранения";
return;
}
submitting.value = true;
clearMessages();
try {
const updated = await updateUser(editingUser.value.id, payload);
success.value = `Пользователь ${updated.username} обновлён`;
editingUser.value = updated;
fillEditForm(updated);
await loadUsers();
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
} finally {
submitting.value = false;
}
}
@@ -146,6 +260,11 @@ onMounted(loadUsers);
margin-bottom: 1.25rem;
}
.users-card-editing {
border-color: #3d5a80;
box-shadow: 0 0 0 1px rgba(61, 90, 128, 0.35);
}
.users-form {
display: flex;
flex-direction: column;
@@ -159,6 +278,26 @@ onMounted(loadUsers);
gap: 0.25rem;
}
.users-inline {
flex-direction: row !important;
align-items: center;
gap: 0.5rem !important;
}
.users-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.users-edit-btn {
white-space: nowrap;
}
.users-row-editing td {
background: rgba(61, 90, 128, 0.15);
}
.users-table {
width: 100%;
border-collapse: collapse;