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
+15
View File
@@ -32,6 +32,7 @@ class CreateUserRequest(BaseModel):
class UpdateUserRequest(BaseModel):
username: str | None = Field(default=None, min_length=2, max_length=64)
role: str | None = None
password: str | None = Field(default=None, min_length=8, max_length=128)
is_active: bool | None = None
@@ -91,6 +92,20 @@ def update_user(
if user is None:
raise HTTPException(status_code=404, detail="User not found")
if body.username is not None:
normalized = body.username.strip()
if not normalized:
raise HTTPException(status_code=422, detail="username is required")
existing = db.scalar(
select(User).where(
func.lower(User.username) == normalized.lower(),
User.id != user.id,
)
)
if existing is not None:
raise HTTPException(status_code=400, detail="username already exists")
user.username = normalized
if body.role is not None:
if body.role not in USER_ROLES:
raise HTTPException(status_code=422, detail=f"role must be one of: {', '.join(sorted(USER_ROLES))}")
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center"
APP_VERSION = "0.5.0"
APP_VERSION = "0.6.0"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+2 -2
View File
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
def test_version_constants():
assert APP_VERSION == "0.5.0"
assert APP_VERSION == "0.6.0"
assert APP_NAME == "Security Alert Center"
assert APP_VERSION_LABEL == "Security Alert Center v.0.5.0"
assert APP_VERSION_LABEL == "Security Alert Center v.0.6.0"
+72
View File
@@ -62,3 +62,75 @@ def test_admin_create_monitor_user(client, jwt_headers):
def test_monitor_cannot_list_users(client, jwt_monitor_headers):
response = client.get("/api/v1/users", headers=jwt_monitor_headers)
assert response.status_code == 403
def test_admin_update_user_role_and_password(client, jwt_headers):
create = client.post(
"/api/v1/users",
headers=jwt_headers,
json={"username": "patch-me", "password": "longpassword1", "role": "monitor"},
)
assert create.status_code == 201
user_id = create.json()["id"]
response = client.patch(
f"/api/v1/users/{user_id}",
headers=jwt_headers,
json={"role": "admin", "password": "newpassword9"},
)
assert response.status_code == 200
body = response.json()
assert body["role"] == "admin"
login = client.post(
"/api/v1/auth/login",
json={"username": "patch-me", "password": "newpassword9"},
)
assert login.status_code == 200
assert login.json()["role"] == "admin"
def test_admin_update_username(client, jwt_headers):
create = client.post(
"/api/v1/users",
headers=jwt_headers,
json={"username": "old-name", "password": "longpassword1", "role": "monitor"},
)
user_id = create.json()["id"]
response = client.patch(
f"/api/v1/users/{user_id}",
headers=jwt_headers,
json={"username": "new-name"},
)
assert response.status_code == 200
assert response.json()["username"] == "new-name"
login = client.post(
"/api/v1/auth/login",
json={"username": "new-name", "password": "longpassword1"},
)
assert login.status_code == 200
def test_admin_deactivate_user(client, jwt_headers):
create = client.post(
"/api/v1/users",
headers=jwt_headers,
json={"username": "to-disable", "password": "longpassword1", "role": "monitor"},
)
user_id = create.json()["id"]
response = client.patch(
f"/api/v1/users/{user_id}",
headers=jwt_headers,
json={"is_active": False},
)
assert response.status_code == 200
assert response.json()["is_active"] is False
login = client.post(
"/api/v1/auth/login",
json={"username": "to-disable", "password": "longpassword1"},
)
assert login.status_code == 401
+4
View File
@@ -15,6 +15,10 @@ sudo chmod 755 /opt/sac-deploy.sh
sudo /opt/sac-deploy.sh
```
Суточные отчёты: `sac-daily-report.service` + timer. Файл `sac-daily-report.timer` в git — шаблон;
на сервере timer **генерируется** из `SAC_DAILY_REPORT_HOUR` / `SAC_DAILY_REPORT_TIMEZONE`
(`deploy/systemd/render-sac-daily-report-timer.sh`, вызывается из `sac-deploy.sh`).
## Альтернатива: Docker
Только для стенда. См. [docs/install-ubuntu-24.04-docker.md](../docs/install-ubuntu-24.04-docker.md).
+18
View File
@@ -122,10 +122,28 @@ else
die "${SERVICE_NAME} запущен, но /health не ответил — journalctl -u ${SERVICE_NAME} -n 80 --no-pager"
fi
RENDER_DAILY_TIMER="${APP_ROOT}/deploy/systemd/render-sac-daily-report-timer.sh"
if [ -f "${RENDER_DAILY_TIMER}" ]; then
chmod +x "${RENDER_DAILY_TIMER}" 2>/dev/null || true
fi
for aux in sac-retention sac-daily-report; do
for suffix in service timer; do
src="${APP_ROOT}/deploy/systemd/${aux}.${suffix}"
dst="/etc/systemd/system/${aux}.${suffix}"
if [ "${aux}" = "sac-daily-report" ] && [ "${suffix}" = "timer" ]; then
if [ -x "${RENDER_DAILY_TIMER}" ]; then
log "Генерация ${dst} из ${CONFIG_FILE} (SAC_DAILY_REPORT_HOUR/TIMEZONE)"
bash "${RENDER_DAILY_TIMER}" "${CONFIG_FILE}" "${dst}"
systemctl daemon-reload
systemctl restart "${aux}.timer" 2>/dev/null || systemctl start "${aux}.timer" 2>/dev/null || true
elif [ -f "${src}" ]; then
log "WARN: ${RENDER_DAILY_TIMER} не найден — копируем статический timer"
cp "${src}" "${dst}"
systemctl daemon-reload
fi
continue
fi
if [ -f "${src}" ]; then
if [ ! -f "${dst}" ] || ! cmp -s "${src}" "${dst}"; then
log "Обновление ${dst}"
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Генерирует sac-daily-report.timer из SAC_DAILY_REPORT_HOUR и SAC_DAILY_REPORT_TIMEZONE.
# Usage: sudo deploy/systemd/render-sac-daily-report-timer.sh [config.env] [output.timer]
set -euo pipefail
CONFIG_FILE="${1:-/opt/security-alert-center/config/sac-api.env}"
OUTPUT="${2:-/etc/systemd/system/sac-daily-report.timer}"
HOUR=9
TZ_NAME="Europe/Moscow"
if [ -f "${CONFIG_FILE}" ]; then
set -a
# shellcheck source=/dev/null
source "${CONFIG_FILE}"
set +a
fi
HOUR="${SAC_DAILY_REPORT_HOUR:-9}"
TZ_NAME="${SAC_DAILY_REPORT_TIMEZONE:-Europe/Moscow}"
if ! [[ "${HOUR}" =~ ^[0-9]+$ ]] || [ "${HOUR}" -lt 0 ] || [ "${HOUR}" -gt 23 ]; then
printf 'render-sac-daily-report-timer: invalid SAC_DAILY_REPORT_HOUR=%s\n' "${HOUR}" >&2
exit 1
fi
HOUR_PADDED="$(printf '%02d' "${HOUR}")"
cat >"${OUTPUT}" <<EOF
[Unit]
Description=Daily SAC report aggregation and notify
[Timer]
OnCalendar=*-*-* ${HOUR_PADDED}:00:00
Timezone=${TZ_NAME}
Persistent=true
[Install]
WantedBy=timers.target
EOF
printf 'render-sac-daily-report-timer: wrote %s (OnCalendar=%s:00:00 Timezone=%s)\n' \
"${OUTPUT}" "${HOUR_PADDED}" "${TZ_NAME}"
+4 -2
View File
@@ -2,8 +2,10 @@
Description=Daily SAC report aggregation and notify
[Timer]
OnCalendar=*-*-* 09:15:00
RandomizedDelaySec=300
# Шаблон по умолчанию (hour=9, Europe/Moscow). На prod timer перегенерируется
# из config/sac-api.env: deploy/systemd/render-sac-daily-report-timer.sh или sac-deploy.sh
OnCalendar=*-*-* 09:00:00
Timezone=Europe/Moscow
Persistent=true
[Install]
+4 -2
View File
@@ -62,7 +62,7 @@ SAC_DAILY_REPORT_REQUIRE_ACTIVITY=true
```
- Job: `cd backend && .venv/bin/python -m app.jobs.daily_report` (ручной прогон: `--force`).
- Timer: `deploy/systemd/sac-daily-report.service` + `.timer` (по умолчанию 09:15 UTC в unit; час проверяется по `SAC_DAILY_REPORT_TIMEZONE`).
- Timer: `deploy/systemd/sac-daily-report.service` + `.timer` — **09:00** в `SAC_DAILY_REPORT_TIMEZONE` (генерируется из `SAC_DAILY_REPORT_HOUR`, без `RandomizedDelaySec`).
- Оповещение: `notify_daily_report` — **не** режется `NOTIFY_MIN_SEVERITY=warning` (severity отчёта `info`).
- UI **Отчёты** и Telegram используют `details.report_html` / `report_body` как у агента.
@@ -70,7 +70,9 @@ SAC_DAILY_REPORT_REQUIRE_ACTIVITY=true
```bash
sudo cp /opt/security-alert-center/deploy/systemd/sac-daily-report.service /etc/systemd/system/
sudo cp /opt/security-alert-center/deploy/systemd/sac-daily-report.timer /etc/systemd/system/
sudo bash /opt/security-alert-center/deploy/systemd/render-sac-daily-report-timer.sh \
/opt/security-alert-center/config/sac-api.env \
/etc/systemd/system/sac-daily-report.timer
sudo systemctl daemon-reload
sudo systemctl enable --now sac-daily-report.timer
```
+3 -1
View File
@@ -65,7 +65,9 @@ systemctl list-timers sac-retention.timer
```bash
sudo cp /opt/security-alert-center/deploy/systemd/sac-daily-report.service /etc/systemd/system/
sudo cp /opt/security-alert-center/deploy/systemd/sac-daily-report.timer /etc/systemd/system/
sudo bash /opt/security-alert-center/deploy/systemd/render-sac-daily-report-timer.sh \
/opt/security-alert-center/config/sac-api.env \
/etc/systemd/system/sac-daily-report.timer
sudo systemctl daemon-reload
sudo systemctl enable --now sac-daily-report.timer
systemctl list-timers sac-daily-report.timer
+1 -1
View File
@@ -106,4 +106,4 @@ curl -sS -X POST "$BASE/api/v1/events" \
|-----------|--------|
| ssh-monitor | 1.2.6-SAC |
| RDP-login-monitor | 1.2.20-SAC |
| security-alert-center | 0.5.0 (`backend/app/version.py`) |
| security-alert-center | 0.6.0 (`backend/app/version.py`) |
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "sac-ui",
"private": true,
"version": "0.5.0",
"version": "0.6.0",
"type": "module",
"scripts": {
"dev": "vite",
+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}`;
+44 -4
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,11 +140,12 @@ 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";
}
saveHostsSortPrefs(sortBy.value, sortDir.value);
}
function sortMark(key: SortKey) {
if (sortBy.value !== key) return "";
+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 {
+174 -35
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({
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;