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:
@@ -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,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}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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}"
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "sac-ui",
|
||||
"private": true,
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+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