feat: Seaca mobile API, enrollment, FCM push and admin UI (0.9.0)

Adds mobile device registration by admin codes, refresh tokens, push channel
in notification policy, and Settings section for managing Seaca clients.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-10 13:19:14 +10:00
parent 86c602cf07
commit 563b836acc
32 changed files with 1916 additions and 45 deletions
+91
View File
@@ -289,6 +289,97 @@ export interface TopTypeItem {
count: number;
}
export interface MobileSettings {
devices_allowed: boolean;
max_devices_per_user: number;
min_app_version: string | null;
fcm_enabled: boolean;
fcm_configured: boolean;
source: string;
}
export interface MobileEnrollmentCode {
id: number;
label: string;
code_prefix: string;
target_user_id: number | null;
target_username: string | null;
login_mode: string;
max_uses: number;
use_count: number;
expires_at: string;
revoked_at: string | null;
created_at: string;
is_active: boolean;
}
export interface MobileEnrollmentCodeCreated extends MobileEnrollmentCode {
enrollment_code: string;
}
export interface MobileDevice {
id: number;
user_id: number;
username: string;
device_uuid: string;
display_name: string;
platform: string;
app_version: string | null;
has_fcm_token: boolean;
enrolled_at: string;
last_seen_at: string | null;
revoked_at: string | null;
is_active: boolean;
}
export function fetchMobileSettings(): Promise<MobileSettings> {
return apiFetch<MobileSettings>("/api/v1/mobile/settings");
}
export function updateMobileSettings(payload: {
devices_allowed: boolean;
max_devices_per_user: number;
min_app_version?: string | null;
}): Promise<MobileSettings> {
return apiFetch<MobileSettings>("/api/v1/mobile/settings", {
method: "PUT",
body: JSON.stringify(payload),
});
}
export function fetchMobileEnrollmentCodes(): Promise<MobileEnrollmentCode[]> {
return apiFetch<MobileEnrollmentCode[]>("/api/v1/mobile/enrollment-codes");
}
export function createMobileEnrollmentCode(payload: {
label?: string;
target_user_id?: number | null;
login_mode?: string;
max_uses?: number;
expires_in_hours?: number;
}): Promise<MobileEnrollmentCodeCreated> {
return apiFetch<MobileEnrollmentCodeCreated>("/api/v1/mobile/enrollment-codes", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function revokeMobileEnrollmentCode(codeId: number): Promise<void> {
return apiFetch<void>(`/api/v1/mobile/enrollment-codes/${codeId}`, { method: "DELETE" });
}
export function fetchMobileDevices(): Promise<MobileDevice[]> {
return apiFetch<MobileDevice[]>("/api/v1/mobile/devices");
}
export function revokeMobileDevice(deviceId: number): Promise<MobileDevice> {
return apiFetch<MobileDevice>(`/api/v1/mobile/devices/${deviceId}`, { method: "DELETE" });
}
export function testMobileDevicePush(deviceId: number): Promise<{ message: string }> {
return apiFetch(`/api/v1/mobile/devices/${deviceId}/test-push`, { method: "POST" });
}
export interface DashboardSummary {
events_last_24h: number;
hosts_total: number;
+1 -1
View File
@@ -1,3 +1,3 @@
export const APP_NAME = "Security Alert Center";
export const APP_VERSION = "0.8.3";
export const APP_VERSION = "0.9.0";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+295 -1
View File
@@ -62,6 +62,10 @@
<input v-model="policyForm.use_email" type="checkbox" />
Email
</label>
<label class="settings-inline">
<input v-model="policyForm.use_mobile" type="checkbox" />
Seaca (push)
</label>
</fieldset>
<p v-if="policyLoaded" class="settings-meta">
Источник: <code>{{ policyLoaded.source }}</code>
@@ -74,6 +78,144 @@
</form>
</section>
<section class="card settings-card settings-policy">
<h2>Мобильные устройства (Seaca)</h2>
<p class="settings-hint settings-hint-top">
Подключение Android-клиента по коду регистрации. Управление пользователями SAC в разделе «Пользователи».
</p>
<form class="settings-form" @submit.prevent="saveMobileSettings">
<label class="settings-field settings-inline">
<input v-model="mobileForm.devices_allowed" type="checkbox" />
Разрешать мобильным устройствам подключаться
</label>
<label class="settings-field">
Макс. устройств на пользователя
<input v-model.number="mobileForm.max_devices_per_user" type="number" min="1" max="50" />
</label>
<label class="settings-field">
Мин. версия приложения (необязательно)
<input v-model="mobileForm.min_app_version" type="text" placeholder="0.1.0" />
</label>
<p v-if="mobileLoaded" class="settings-meta">
FCM: {{ mobileLoaded.fcm_configured ? "настроен" : "не настроен" }}
· источник: <code>{{ mobileLoaded.source }}</code>
</p>
<div class="settings-actions">
<button type="submit" :disabled="savingMobile">
{{ savingMobile ? "Сохранение…" : "Сохранить мобильные" }}
</button>
</div>
</form>
<h3>Коды регистрации</h3>
<form class="settings-form settings-mobile-code-form" @submit.prevent="createEnrollmentCode">
<label class="settings-field">
Метка
<input v-model="codeForm.label" type="text" placeholder="Телефон Иванова" />
</label>
<label class="settings-field">
Пользователь (необязательно)
<select v-model="codeForm.target_user_id">
<option :value="null"> любой с паролем </option>
<option v-for="u in sacUsers" :key="u.id" :value="u.id">{{ u.username }} ({{ u.role }})</option>
</select>
</label>
<label class="settings-field">
Способ входа
<select v-model="codeForm.login_mode">
<option value="password">Логин и пароль SAC + код</option>
<option value="code_only">Только код (пользователь привязан)</option>
</select>
</label>
<label class="settings-field">
Срок (часов)
<input v-model.number="codeForm.expires_in_hours" type="number" min="1" max="720" />
</label>
<div class="settings-actions">
<button type="submit" :disabled="creatingCode">
{{ creatingCode ? "Создание…" : "Выдать код" }}
</button>
</div>
</form>
<p v-if="lastCreatedCode" class="settings-created-code">
Код (скопируйте сейчас): <code>{{ lastCreatedCode }}</code>
</p>
<div class="settings-mobile-table-wrap">
<table class="settings-severity-table">
<thead>
<tr>
<th>Метка</th>
<th>Префикс</th>
<th>Пользователь</th>
<th>Вход</th>
<th>Использований</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<tr v-for="code in enrollmentCodes" :key="code.id">
<td>{{ code.label || "—" }}</td>
<td><code>{{ code.code_prefix }}</code></td>
<td>{{ code.target_username || "—" }}</td>
<td>{{ code.login_mode }}</td>
<td>{{ code.use_count }}/{{ code.max_uses }}</td>
<td>
<button
v-if="code.is_active"
type="button"
class="secondary"
@click="revokeCode(code.id)"
>
Отозвать
</button>
</td>
</tr>
</tbody>
</table>
</div>
<h3>Подключённые устройства</h3>
<div class="settings-mobile-table-wrap">
<table class="settings-severity-table">
<thead>
<tr>
<th>Имя</th>
<th>Пользователь</th>
<th>FCM</th>
<th>Последняя активность</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<tr v-for="device in mobileDevices" :key="device.id">
<td>{{ device.display_name }}</td>
<td>{{ device.username }}</td>
<td>{{ device.has_fcm_token ? "да" : "нет" }}</td>
<td>{{ device.last_seen_at || device.enrolled_at }}</td>
<td class="settings-mobile-actions">
<button
v-if="device.is_active && device.has_fcm_token"
type="button"
class="secondary"
@click="testDevicePush(device.id)"
>
Тест push
</button>
<button
v-if="device.is_active"
type="button"
class="secondary"
@click="revokeDevice(device.id)"
>
Отключить
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="card settings-card">
<h2>Severity по типу события</h2>
<p class="settings-hint settings-hint-top">
@@ -268,7 +410,22 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { apiFetch } from "../api";
import {
apiFetch,
createMobileEnrollmentCode,
fetchMobileDevices,
fetchMobileEnrollmentCodes,
fetchMobileSettings,
fetchUsers,
revokeMobileDevice,
revokeMobileEnrollmentCode,
testMobileDevicePush,
updateMobileSettings,
type MobileDevice,
type MobileEnrollmentCode,
type MobileSettings,
type UserSummary,
} from "../api";
interface TelegramSettings {
enabled: boolean;
@@ -309,6 +466,7 @@ interface NotificationPolicy {
use_telegram: boolean;
use_webhook: boolean;
use_email: boolean;
use_mobile: boolean;
source: string;
}
@@ -334,6 +492,8 @@ const savingTelegram = ref(false);
const savingWebhook = ref(false);
const savingEmail = ref(false);
const savingSeverity = ref(false);
const savingMobile = ref(false);
const creatingCode = ref(false);
const testingTelegram = ref(false);
const testingWebhook = ref(false);
const testingEmail = ref(false);
@@ -346,12 +506,32 @@ const policyLoaded = ref<NotificationPolicy | null>(null);
const uiLoaded = ref<UiSettings | null>(null);
const severityRows = ref<SeverityOverrideRowUi[]>([]);
const severityFilter = ref("");
const mobileLoaded = ref<MobileSettings | null>(null);
const enrollmentCodes = ref<MobileEnrollmentCode[]>([]);
const mobileDevices = ref<MobileDevice[]>([]);
const sacUsers = ref<UserSummary[]>([]);
const lastCreatedCode = ref("");
const policyForm = reactive({
min_severity: "warning",
use_telegram: true,
use_webhook: false,
use_email: false,
use_mobile: false,
});
const mobileForm = reactive({
devices_allowed: false,
max_devices_per_user: 3,
min_app_version: "",
});
const codeForm = reactive({
label: "",
target_user_id: null as number | null,
login_mode: "password",
expires_in_hours: 24,
max_uses: 1,
});
const uiForm = reactive({
@@ -486,6 +666,94 @@ async function saveUiSettings() {
}
}
async function loadMobileSection() {
mobileLoaded.value = await fetchMobileSettings();
mobileForm.devices_allowed = mobileLoaded.value.devices_allowed;
mobileForm.max_devices_per_user = mobileLoaded.value.max_devices_per_user;
mobileForm.min_app_version = mobileLoaded.value.min_app_version ?? "";
enrollmentCodes.value = await fetchMobileEnrollmentCodes();
mobileDevices.value = await fetchMobileDevices();
const users = await fetchUsers();
sacUsers.value = users.items;
}
async function saveMobileSettings() {
savingMobile.value = true;
error.value = "";
success.value = "";
try {
mobileLoaded.value = await updateMobileSettings({
devices_allowed: mobileForm.devices_allowed,
max_devices_per_user: mobileForm.max_devices_per_user,
min_app_version: mobileForm.min_app_version.trim() || null,
});
success.value = "Настройки мобильных устройств сохранены.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
} finally {
savingMobile.value = false;
}
}
async function createEnrollmentCode() {
creatingCode.value = true;
error.value = "";
success.value = "";
lastCreatedCode.value = "";
try {
const created = await createMobileEnrollmentCode({
label: codeForm.label,
target_user_id: codeForm.target_user_id,
login_mode: codeForm.login_mode,
max_uses: codeForm.max_uses,
expires_in_hours: codeForm.expires_in_hours,
});
lastCreatedCode.value = created.enrollment_code;
enrollmentCodes.value = await fetchMobileEnrollmentCodes();
success.value = "Код регистрации создан.";
codeForm.label = "";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка создания кода";
} finally {
creatingCode.value = false;
}
}
async function revokeCode(codeId: number) {
error.value = "";
success.value = "";
try {
await revokeMobileEnrollmentCode(codeId);
enrollmentCodes.value = await fetchMobileEnrollmentCodes();
success.value = "Код отозван.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка";
}
}
async function revokeDevice(deviceId: number) {
error.value = "";
success.value = "";
try {
await revokeMobileDevice(deviceId);
mobileDevices.value = await fetchMobileDevices();
success.value = "Устройство отключено.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка";
}
}
async function testDevicePush(deviceId: number) {
error.value = "";
success.value = "";
try {
const res = await testMobileDevicePush(deviceId);
success.value = res.message;
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка push";
}
}
async function load() {
loading.value = true;
error.value = "";
@@ -501,6 +769,7 @@ async function load() {
policyForm.use_telegram = data.policy.use_telegram;
policyForm.use_webhook = data.policy.use_webhook;
policyForm.use_email = data.policy.use_email;
policyForm.use_mobile = data.policy.use_mobile ?? false;
telegramLoaded.value = data.telegram;
webhookLoaded.value = data.webhook;
emailLoaded.value = data.email;
@@ -522,6 +791,7 @@ async function load() {
emailForm.smtp_ssl = data.email.smtp_ssl;
await loadUiSettings();
await loadSeverityOverrides();
await loadMobileSection();
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
} finally {
@@ -541,6 +811,7 @@ async function savePolicy() {
use_telegram: policyForm.use_telegram,
use_webhook: policyForm.use_webhook,
use_email: policyForm.use_email,
use_mobile: policyForm.use_mobile,
}),
});
success.value = "Правило оповещений сохранено.";
@@ -816,4 +1087,27 @@ onMounted(load);
.settings-severity-table select {
min-width: 11rem;
}
.settings-mobile-table-wrap {
max-height: 16rem;
overflow: auto;
border: 1px solid #2a3441;
border-radius: 6px;
margin-bottom: 1rem;
}
.settings-mobile-code-form {
margin-top: 0.5rem;
}
.settings-created-code {
color: #6dd196;
word-break: break-all;
}
.settings-mobile-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
</style>