fix(mobile): повторный enroll и удаление устройств в UI (0.9.10).
При повторной привязке Seaca переиспользуется запись device_uuid вместо INSERT.
Добавлены POST /devices/{id}/revoke и DELETE для полного удаления записи в настройках SAC.
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Создание и проверка кодов регистрации Seaca."""
|
"""Создание и проверка кодов регистрации Seaca."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -223,27 +223,46 @@ 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
|
|
||||||
|
|
||||||
|
if existing is not None:
|
||||||
|
revoke_refresh_tokens_for_device(db, existing.id)
|
||||||
|
existing.revoked_at = None
|
||||||
|
existing.user_id = user.id
|
||||||
|
existing.display_name = cleaned_display
|
||||||
|
existing.platform = cleaned_platform
|
||||||
|
existing.app_version = cleaned_app_version
|
||||||
|
existing.fcm_token = cleaned_fcm
|
||||||
|
existing.fcm_token_updated_at = now if cleaned_fcm else None
|
||||||
|
existing.enrollment_code_id = code_row.id
|
||||||
|
existing.last_seen_at = now
|
||||||
|
device = existing
|
||||||
|
else:
|
||||||
device = MobileDevice(
|
device = MobileDevice(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
device_uuid=cleaned_uuid,
|
device_uuid=cleaned_uuid,
|
||||||
display_name=(display_name or "").strip() or "Android",
|
display_name=cleaned_display,
|
||||||
platform=(platform or "android").strip() or "android",
|
platform=cleaned_platform,
|
||||||
app_version=(app_version or "").strip() or None,
|
app_version=cleaned_app_version,
|
||||||
fcm_token=(fcm_token or "").strip() or None,
|
fcm_token=cleaned_fcm,
|
||||||
fcm_token_updated_at=now if fcm_token else None,
|
fcm_token_updated_at=now if cleaned_fcm else None,
|
||||||
enrollment_code_id=code_row.id,
|
enrollment_code_id=code_row.id,
|
||||||
last_seen_at=now,
|
last_seen_at=now,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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() == []
|
||||||
|
|||||||
@@ -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
@@ -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 }> {
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
|
|||||||
Reference in New Issue
Block a user