fix(mobile): повторный enroll и удаление устройств в UI (0.9.10).

При повторной привязке Seaca переиспользуется запись device_uuid вместо INSERT.
Добавлены POST /devices/{id}/revoke и DELETE для полного удаления записи в настройках SAC.
This commit is contained in:
2026-06-13 16:36:07 +10:00
parent e022f2f213
commit 3d36faa49d
10 changed files with 186 additions and 39 deletions
+16 -3
View File
@@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -13,6 +13,7 @@ from app.services.mobile_devices import (
get_device_or_raise, get_device_or_raise,
list_mobile_devices, list_mobile_devices,
revoke_device, revoke_device,
delete_device_record,
touch_device, touch_device,
update_fcm_token, update_fcm_token,
assert_device_active, assert_device_active,
@@ -257,8 +258,8 @@ def get_devices(
return [_device_to_response(item) for item in list_mobile_devices(db)] return [_device_to_response(item) for item in list_mobile_devices(db)]
@router.delete("/devices/{device_id}", response_model=MobileDeviceResponse) @router.post("/devices/{device_id}/revoke", response_model=MobileDeviceResponse)
def delete_device( def post_revoke_device(
device_id: int, device_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user=Depends(require_admin), _user=Depends(require_admin),
@@ -270,6 +271,18 @@ def delete_device(
return _device_to_response(summary) return _device_to_response(summary)
@router.delete("/devices/{device_id}", status_code=204)
def delete_device(
device_id: int,
db: Session = Depends(get_db),
_user=Depends(require_admin),
) -> None:
try:
delete_device_record(db, device_id)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/devices/{device_id}/test-push", response_model=TestPushResponse) @router.post("/devices/{device_id}/test-push", response_model=TestPushResponse)
def test_device_push( def test_device_push(
device_id: int, device_id: int,
+8 -1
View File
@@ -1,4 +1,4 @@
"""Управление зарегистрированными устройствами Seaca.""" """Управление зарегистрированными устройствами Seaca."""
from __future__ import annotations from __future__ import annotations
@@ -86,6 +86,13 @@ def revoke_device(db: Session, device_id: int) -> MobileDeviceSummary:
return _to_summary(device, username) return _to_summary(device, username)
def delete_device_record(db: Session, device_id: int) -> None:
device = get_device_or_raise(db, device_id)
revoke_refresh_tokens_for_device(db, device.id)
db.delete(device)
db.commit()
def update_fcm_token(db: Session, device: MobileDevice, fcm_token: str) -> None: def update_fcm_token(db: Session, device: MobileDevice, fcm_token: str) -> None:
cleaned = (fcm_token or "").strip() cleaned = (fcm_token or "").strip()
if not cleaned: if not cleaned:
+40 -21
View File
@@ -1,4 +1,4 @@
"""Создание и проверка кодов регистрации Seaca.""" """Создание и проверка кодов регистрации Seaca."""
from __future__ import annotations from __future__ import annotations
@@ -223,31 +223,50 @@ def enroll_device(
if code_row.target_user_id is not None and user.id != code_row.target_user_id: if code_row.target_user_id is not None and user.id != code_row.target_user_id:
raise ValueError("enrollment code is bound to another user") raise ValueError("enrollment code is bound to another user")
existing = db.scalar(select(MobileDevice).where(MobileDevice.device_uuid == cleaned_uuid))
if existing is not None and existing.user_id != user.id:
raise ValueError("device_uuid already registered to another user")
active_count = _count_active_devices(db, user.id) active_count = _count_active_devices(db, user.id)
if (
existing is not None
and existing.user_id == user.id
and existing.revoked_at is None
):
active_count -= 1
if active_count >= mobile_cfg.max_devices_per_user: if active_count >= mobile_cfg.max_devices_per_user:
raise ValueError("device limit reached for this user") raise ValueError("device limit reached for this user")
existing = db.scalar(select(MobileDevice).where(MobileDevice.device_uuid == cleaned_uuid)) cleaned_display = (display_name or "").strip() or "Android"
if existing is not None: cleaned_platform = (platform or "android").strip() or "android"
if existing.revoked_at is None and existing.user_id != user.id: cleaned_app_version = (app_version or "").strip() or None
raise ValueError("device_uuid already registered to another user") cleaned_fcm = (fcm_token or "").strip() or None
if existing.revoked_at is None:
revoke_refresh_tokens_for_device(db, existing.id)
existing.revoked_at = now
existing = None
device = MobileDevice( if existing is not None:
user_id=user.id, revoke_refresh_tokens_for_device(db, existing.id)
device_uuid=cleaned_uuid, existing.revoked_at = None
display_name=(display_name or "").strip() or "Android", existing.user_id = user.id
platform=(platform or "android").strip() or "android", existing.display_name = cleaned_display
app_version=(app_version or "").strip() or None, existing.platform = cleaned_platform
fcm_token=(fcm_token or "").strip() or None, existing.app_version = cleaned_app_version
fcm_token_updated_at=now if fcm_token else None, existing.fcm_token = cleaned_fcm
enrollment_code_id=code_row.id, existing.fcm_token_updated_at = now if cleaned_fcm else None
last_seen_at=now, existing.enrollment_code_id = code_row.id
) existing.last_seen_at = now
db.add(device) device = existing
else:
device = MobileDevice(
user_id=user.id,
device_uuid=cleaned_uuid,
display_name=cleaned_display,
platform=cleaned_platform,
app_version=cleaned_app_version,
fcm_token=cleaned_fcm,
fcm_token_updated_at=now if cleaned_fcm else None,
enrollment_code_id=code_row.id,
last_seen_at=now,
)
db.add(device)
code_row.use_count += 1 code_row.use_count += 1
db.flush() db.flush()
+2 -2
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI).""" """Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center" APP_NAME = "Security Alert Center"
APP_VERSION = "0.9.9" APP_VERSION = "0.9.10"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+3 -3
View File
@@ -1,9 +1,9 @@
"""Version and health contract smoke tests (no DB required).""" """Version and health contract smoke tests (no DB required)."""
from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
def test_version_constants(): def test_version_constants():
assert APP_VERSION == "0.9.0" assert APP_VERSION == "0.9.10"
assert APP_NAME == "Security Alert Center" assert APP_NAME == "Security Alert Center"
assert APP_VERSION_LABEL == "Security Alert Center v.0.9.0" assert APP_VERSION_LABEL == "Security Alert Center v.0.9.10"
+79 -2
View File
@@ -1,4 +1,4 @@
"""Mobile / Seaca API.""" """Mobile / Seaca API."""
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -139,7 +139,7 @@ def test_revoke_device_blocks_token(client, db_session, jwt_headers):
data = enroll.json() data = enroll.json()
headers = {"Authorization": f"Bearer {data['access_token']}"} headers = {"Authorization": f"Bearer {data['access_token']}"}
revoke = client.delete(f"/api/v1/mobile/devices/{data['device_id']}", headers=jwt_headers) revoke = client.post(f"/api/v1/mobile/devices/{data['device_id']}/revoke", headers=jwt_headers)
assert revoke.status_code == 200 assert revoke.status_code == 200
me = client.put( me = client.put(
@@ -148,3 +148,80 @@ def test_revoke_device_blocks_token(client, db_session, jwt_headers):
json={"fcm_token": "new-token"}, json={"fcm_token": "new-token"},
) )
assert me.status_code == 401 assert me.status_code == 401
def test_reenroll_same_device_after_revoke(client, db_session, jwt_headers):
_enable_mobile(db_session)
device_uuid = "uuid-reenroll-001"
first_code = client.post(
"/api/v1/mobile/enrollment-codes",
headers=jwt_headers,
json={"login_mode": "password"},
).json()["enrollment_code"]
first = client.post(
"/api/v1/mobile/enroll",
json={
"enrollment_code": first_code,
"username": "test-monitor",
"password": "test-monitor-password",
"device_uuid": device_uuid,
"display_name": "Phone A",
},
)
assert first.status_code == 200
first_device_id = first.json()["device_id"]
revoke = client.post(f"/api/v1/mobile/devices/{first_device_id}/revoke", headers=jwt_headers)
assert revoke.status_code == 200
second_code = client.post(
"/api/v1/mobile/enrollment-codes",
headers=jwt_headers,
json={"login_mode": "password"},
).json()["enrollment_code"]
second = client.post(
"/api/v1/mobile/enroll",
json={
"enrollment_code": second_code,
"username": "test-monitor",
"password": "test-monitor-password",
"device_uuid": device_uuid,
"display_name": "Phone B",
},
)
assert second.status_code == 200
assert second.json()["device_id"] == first_device_id
devices = client.get("/api/v1/mobile/devices", headers=jwt_headers)
assert devices.status_code == 200
assert len(devices.json()) == 1
assert devices.json()[0]["display_name"] == "Phone B"
assert devices.json()[0]["is_active"] is True
def test_delete_device_record(client, db_session, jwt_headers):
_enable_mobile(db_session)
create = client.post(
"/api/v1/mobile/enrollment-codes",
headers=jwt_headers,
json={"login_mode": "password"},
)
code = create.json()["enrollment_code"]
enroll = client.post(
"/api/v1/mobile/enroll",
json={
"enrollment_code": code,
"username": "test-monitor",
"password": "test-monitor-password",
"device_uuid": "uuid-delete-001",
},
)
device_id = enroll.json()["device_id"]
deleted = client.delete(f"/api/v1/mobile/devices/{device_id}", headers=jwt_headers)
assert deleted.status_code == 204
devices = client.get("/api/v1/mobile/devices", headers=jwt_headers)
assert devices.status_code == 200
assert devices.json() == []
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "sac-ui", "name": "sac-ui",
"private": true, "private": true,
"version": "0.7.4", "version": "0.9.10",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+6 -2
View File
@@ -1,4 +1,4 @@
const TOKEN_KEY = "sac_token"; const TOKEN_KEY = "sac_token";
const ROLE_KEY = "sac_role"; const ROLE_KEY = "sac_role";
export function getToken(): string | null { export function getToken(): string | null {
@@ -373,7 +373,11 @@ export function fetchMobileDevices(): Promise<MobileDevice[]> {
} }
export function revokeMobileDevice(deviceId: number): Promise<MobileDevice> { export function revokeMobileDevice(deviceId: number): Promise<MobileDevice> {
return apiFetch<MobileDevice>(`/api/v1/mobile/devices/${deviceId}`, { method: "DELETE" }); return apiFetch<MobileDevice>(`/api/v1/mobile/devices/${deviceId}/revoke`, { method: "POST" });
}
export function deleteMobileDevice(deviceId: number): Promise<void> {
return apiFetch<void>(`/api/v1/mobile/devices/${deviceId}`, { method: "DELETE" });
} }
export function testMobileDevicePush(deviceId: number): Promise<{ message: string }> { export function testMobileDevicePush(deviceId: number): Promise<{ message: string }> {
+2 -2
View File
@@ -1,4 +1,4 @@
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
export const APP_NAME = "Security Alert Center"; export const APP_NAME = "Security Alert Center";
export const APP_VERSION = "0.9.9"; export const APP_VERSION = "0.9.10";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
+28 -1
View File
@@ -1,4 +1,4 @@
<template> <template>
<div class="settings-page"> <div class="settings-page">
<h1>Настройки</h1> <h1>Настройки</h1>
<p class="settings-intro"> <p class="settings-intro">
@@ -190,6 +190,7 @@
<tr> <tr>
<th>Имя</th> <th>Имя</th>
<th>Пользователь</th> <th>Пользователь</th>
<th>Статус</th>
<th>FCM</th> <th>FCM</th>
<th>Последняя активность</th> <th>Последняя активность</th>
<th>Действия</th> <th>Действия</th>
@@ -199,6 +200,7 @@
<tr v-for="device in mobileDevices" :key="device.id"> <tr v-for="device in mobileDevices" :key="device.id">
<td>{{ device.display_name }}</td> <td>{{ device.display_name }}</td>
<td>{{ device.username }}</td> <td>{{ device.username }}</td>
<td>{{ device.is_active ? "активно" : "отключено" }}</td>
<td>{{ device.has_fcm_token ? "да" : "нет" }}</td> <td>{{ device.has_fcm_token ? "да" : "нет" }}</td>
<td>{{ device.last_seen_at || device.enrolled_at }}</td> <td>{{ device.last_seen_at || device.enrolled_at }}</td>
<td class="settings-mobile-actions"> <td class="settings-mobile-actions">
@@ -219,6 +221,13 @@
> >
Отключить Отключить
</button> </button>
<button
type="button"
class="danger"
@click="deleteDevice(device.id)"
>
Удалить
</button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -428,6 +437,7 @@ import {
fetchMobileSettings, fetchMobileSettings,
fetchUsers, fetchUsers,
revokeMobileDevice, revokeMobileDevice,
deleteMobileDevice,
revokeMobileEnrollmentCode, revokeMobileEnrollmentCode,
testMobileDevicePush, testMobileDevicePush,
updateMobileSettings, updateMobileSettings,
@@ -781,6 +791,23 @@ async function revokeDevice(deviceId: number) {
} }
} }
async function deleteDevice(deviceId: number) {
const device = mobileDevices.value.find((item) => item.id === deviceId);
const label = device?.display_name || `#${deviceId}`;
if (!window.confirm(`Удалить запись устройства «${label}» из базы? Повторная привязка потребует новый код enroll.`)) {
return;
}
error.value = "";
success.value = "";
try {
await deleteMobileDevice(deviceId);
mobileDevices.value = await fetchMobileDevices();
success.value = "Запись устройства удалена.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка";
}
}
async function testDevicePush(deviceId: number) { async function testDevicePush(deviceId: number) {
error.value = ""; error.value = "";
success.value = ""; success.value = "";