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:
+56
-1
@@ -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,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}`;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user