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:
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
## Статус
|
## Статус
|
||||||
|
|
||||||
**Версия:** `0.8.3`
|
**Версия:** `0.9.0`
|
||||||
**Стек:** FastAPI, PostgreSQL, Vue 3, JWT, SSE
|
**Стек:** FastAPI, PostgreSQL, Vue 3, JWT, SSE
|
||||||
**Деплой:** `sudo /opt/sac-deploy.sh` (см. `deploy/sac-deploy.sh`)
|
**Деплой:** `sudo /opt/sac-deploy.sh` (см. `deploy/sac-deploy.sh`)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""mobile settings, enrollment codes, devices, refresh tokens; policy use_mobile
|
||||||
|
|
||||||
|
Revision ID: 014
|
||||||
|
Revises: 013
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "014"
|
||||||
|
down_revision: Union[str, None] = "013"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
MOBILE_SETTINGS_ROW_ID = 1
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"notification_policy",
|
||||||
|
sa.Column("use_mobile", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"mobile_settings",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("devices_allowed", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("max_devices_per_user", sa.Integer(), nullable=False, server_default="3"),
|
||||||
|
sa.Column("min_app_version", sa.String(length=32), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"INSERT INTO mobile_settings (id, devices_allowed, max_devices_per_user) "
|
||||||
|
f"VALUES ({MOBILE_SETTINGS_ROW_ID}, false, 3)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"mobile_enrollment_codes",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("label", sa.String(length=128), nullable=False, server_default=""),
|
||||||
|
sa.Column("code_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("code_prefix", sa.String(length=16), nullable=False),
|
||||||
|
sa.Column("created_by_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("target_user_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("login_mode", sa.String(length=16), nullable=False, server_default="password"),
|
||||||
|
sa.Column("max_uses", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("use_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["sac_users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["target_user_id"], ["sac_users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_mobile_enrollment_codes_code_hash", "mobile_enrollment_codes", ["code_hash"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"mobile_devices",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("device_uuid", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(length=128), nullable=False, server_default=""),
|
||||||
|
sa.Column("platform", sa.String(length=32), nullable=False, server_default="android"),
|
||||||
|
sa.Column("app_version", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column("fcm_token", sa.String(length=512), nullable=True),
|
||||||
|
sa.Column("fcm_token_updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("enrollment_code_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("enrolled_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["sac_users.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["enrollment_code_id"], ["mobile_enrollment_codes.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_mobile_devices_device_uuid", "mobile_devices", ["device_uuid"], unique=True)
|
||||||
|
op.create_index("ix_mobile_devices_user_id", "mobile_devices", ["user_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"mobile_refresh_tokens",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("device_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["device_id"], ["mobile_devices.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_mobile_refresh_tokens_token_hash", "mobile_refresh_tokens", ["token_hash"], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_mobile_refresh_tokens_token_hash", table_name="mobile_refresh_tokens")
|
||||||
|
op.drop_table("mobile_refresh_tokens")
|
||||||
|
op.drop_index("ix_mobile_devices_user_id", table_name="mobile_devices")
|
||||||
|
op.drop_index("ix_mobile_devices_device_uuid", table_name="mobile_devices")
|
||||||
|
op.drop_table("mobile_devices")
|
||||||
|
op.drop_index("ix_mobile_enrollment_codes_code_hash", table_name="mobile_enrollment_codes")
|
||||||
|
op.drop_table("mobile_enrollment_codes")
|
||||||
|
op.drop_table("mobile_settings")
|
||||||
|
op.drop_column("notification_policy", "use_mobile")
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth.jwt_auth import create_access_token, get_current_user, require_admin, CurrentUser
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.mobile_settings import LOGIN_MODES
|
||||||
|
from app.services.mobile_devices import (
|
||||||
|
get_device_or_raise,
|
||||||
|
list_mobile_devices,
|
||||||
|
revoke_device,
|
||||||
|
touch_device,
|
||||||
|
update_fcm_token,
|
||||||
|
assert_device_active,
|
||||||
|
)
|
||||||
|
from app.services.mobile_enrollment import (
|
||||||
|
create_enrollment_code,
|
||||||
|
enroll_device,
|
||||||
|
list_enrollment_codes,
|
||||||
|
revoke_enrollment_code,
|
||||||
|
)
|
||||||
|
from app.services.mobile_notify import (
|
||||||
|
MobileNotConfiguredError,
|
||||||
|
MobileSendError,
|
||||||
|
get_effective_fcm_config,
|
||||||
|
send_test_push,
|
||||||
|
)
|
||||||
|
from app.services.mobile_settings import upsert_mobile_settings, get_effective_mobile_settings
|
||||||
|
from app.services.mobile_tokens import find_valid_refresh_token, revoke_refresh_tokens_for_device, store_refresh_token
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/mobile", tags=["mobile"])
|
||||||
|
|
||||||
|
|
||||||
|
class MobileSettingsResponse(BaseModel):
|
||||||
|
devices_allowed: bool
|
||||||
|
max_devices_per_user: int
|
||||||
|
min_app_version: str | None = None
|
||||||
|
fcm_enabled: bool
|
||||||
|
fcm_configured: bool
|
||||||
|
source: str
|
||||||
|
|
||||||
|
|
||||||
|
class MobileSettingsUpdate(BaseModel):
|
||||||
|
devices_allowed: bool
|
||||||
|
max_devices_per_user: int = Field(ge=1, le=50)
|
||||||
|
min_app_version: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EnrollmentCodeCreate(BaseModel):
|
||||||
|
label: str = ""
|
||||||
|
target_user_id: int | None = None
|
||||||
|
login_mode: str = "password"
|
||||||
|
max_uses: int = Field(default=1, ge=1, le=100)
|
||||||
|
expires_in_hours: int = Field(default=24, ge=1, le=720)
|
||||||
|
|
||||||
|
|
||||||
|
class EnrollmentCodeResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
label: str
|
||||||
|
code_prefix: str
|
||||||
|
target_user_id: int | None
|
||||||
|
target_username: str | None
|
||||||
|
login_mode: str
|
||||||
|
max_uses: int
|
||||||
|
use_count: int
|
||||||
|
expires_at: datetime
|
||||||
|
revoked_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class EnrollmentCodeCreatedResponse(EnrollmentCodeResponse):
|
||||||
|
enrollment_code: str = Field(description="Показывается один раз при создании")
|
||||||
|
|
||||||
|
|
||||||
|
class MobileDeviceResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
device_uuid: str
|
||||||
|
display_name: str
|
||||||
|
platform: str
|
||||||
|
app_version: str | None
|
||||||
|
has_fcm_token: bool
|
||||||
|
enrolled_at: datetime
|
||||||
|
last_seen_at: datetime | None
|
||||||
|
revoked_at: datetime | None
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class EnrollRequest(BaseModel):
|
||||||
|
enrollment_code: str
|
||||||
|
username: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
device_uuid: str
|
||||||
|
display_name: str = "Android"
|
||||||
|
platform: str = "android"
|
||||||
|
app_version: str | None = None
|
||||||
|
fcm_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EnrollResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
device_id: int
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
device_uuid: str
|
||||||
|
|
||||||
|
|
||||||
|
class FcmTokenUpdate(BaseModel):
|
||||||
|
fcm_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class TestPushResponse(BaseModel):
|
||||||
|
ok: bool = True
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_response(db: Session) -> MobileSettingsResponse:
|
||||||
|
cfg = get_effective_mobile_settings(db)
|
||||||
|
fcm = get_effective_fcm_config()
|
||||||
|
return MobileSettingsResponse(
|
||||||
|
devices_allowed=cfg.devices_allowed,
|
||||||
|
max_devices_per_user=cfg.max_devices_per_user,
|
||||||
|
min_app_version=cfg.min_app_version,
|
||||||
|
fcm_enabled=fcm.enabled,
|
||||||
|
fcm_configured=fcm.configured,
|
||||||
|
source=cfg.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _code_to_response(item) -> EnrollmentCodeResponse:
|
||||||
|
return EnrollmentCodeResponse(
|
||||||
|
id=item.id,
|
||||||
|
label=item.label,
|
||||||
|
code_prefix=item.code_prefix,
|
||||||
|
target_user_id=item.target_user_id,
|
||||||
|
target_username=item.target_username,
|
||||||
|
login_mode=item.login_mode,
|
||||||
|
max_uses=item.max_uses,
|
||||||
|
use_count=item.use_count,
|
||||||
|
expires_at=item.expires_at,
|
||||||
|
revoked_at=item.revoked_at,
|
||||||
|
created_at=item.created_at,
|
||||||
|
is_active=item.is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _device_to_response(item) -> MobileDeviceResponse:
|
||||||
|
return MobileDeviceResponse(
|
||||||
|
id=item.id,
|
||||||
|
user_id=item.user_id,
|
||||||
|
username=item.username,
|
||||||
|
device_uuid=item.device_uuid,
|
||||||
|
display_name=item.display_name,
|
||||||
|
platform=item.platform,
|
||||||
|
app_version=item.app_version,
|
||||||
|
has_fcm_token=item.has_fcm_token,
|
||||||
|
enrolled_at=item.enrolled_at,
|
||||||
|
last_seen_at=item.last_seen_at,
|
||||||
|
revoked_at=item.revoked_at,
|
||||||
|
is_active=item.is_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_user_id(db: Session, username: str) -> int | None:
|
||||||
|
user = db.scalar(select(User).where(func.lower(User.username) == username.lower()))
|
||||||
|
return user.id if user else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings", response_model=MobileSettingsResponse)
|
||||||
|
def get_mobile_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> MobileSettingsResponse:
|
||||||
|
return _settings_response(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings", response_model=MobileSettingsResponse)
|
||||||
|
def update_mobile_settings(
|
||||||
|
body: MobileSettingsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> MobileSettingsResponse:
|
||||||
|
try:
|
||||||
|
upsert_mobile_settings(
|
||||||
|
db,
|
||||||
|
devices_allowed=body.devices_allowed,
|
||||||
|
max_devices_per_user=body.max_devices_per_user,
|
||||||
|
min_app_version=body.min_app_version,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
return _settings_response(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/enrollment-codes", response_model=list[EnrollmentCodeResponse])
|
||||||
|
def get_enrollment_codes(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> list[EnrollmentCodeResponse]:
|
||||||
|
return [_code_to_response(item) for item in list_enrollment_codes(db)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/enrollment-codes", response_model=EnrollmentCodeCreatedResponse)
|
||||||
|
def post_enrollment_code(
|
||||||
|
body: EnrollmentCodeCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_admin),
|
||||||
|
) -> EnrollmentCodeCreatedResponse:
|
||||||
|
if body.login_mode not in LOGIN_MODES:
|
||||||
|
raise HTTPException(status_code=422, detail=f"login_mode must be one of: {sorted(LOGIN_MODES)}")
|
||||||
|
try:
|
||||||
|
summary, plaintext = create_enrollment_code(
|
||||||
|
db,
|
||||||
|
created_by_user_id=_admin_user_id(db, user.username),
|
||||||
|
label=body.label,
|
||||||
|
target_user_id=body.target_user_id,
|
||||||
|
login_mode=body.login_mode,
|
||||||
|
max_uses=body.max_uses,
|
||||||
|
expires_in_hours=body.expires_in_hours,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
base = _code_to_response(summary)
|
||||||
|
return EnrollmentCodeCreatedResponse(**base.model_dump(), enrollment_code=plaintext)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/enrollment-codes/{code_id}", status_code=204)
|
||||||
|
def delete_enrollment_code(
|
||||||
|
code_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
revoke_enrollment_code(db, code_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/devices", response_model=list[MobileDeviceResponse])
|
||||||
|
def get_devices(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> list[MobileDeviceResponse]:
|
||||||
|
return [_device_to_response(item) for item in list_mobile_devices(db)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/devices/{device_id}", response_model=MobileDeviceResponse)
|
||||||
|
def delete_device(
|
||||||
|
device_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> MobileDeviceResponse:
|
||||||
|
try:
|
||||||
|
summary = revoke_device(db, device_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
return _device_to_response(summary)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/devices/{device_id}/test-push", response_model=TestPushResponse)
|
||||||
|
def test_device_push(
|
||||||
|
device_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user=Depends(require_admin),
|
||||||
|
) -> TestPushResponse:
|
||||||
|
try:
|
||||||
|
send_test_push(db=db, device_id=device_id)
|
||||||
|
except MobileNotConfiguredError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
except MobileSendError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
return TestPushResponse(message="Тестовое push отправлено на устройство")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/enroll", response_model=EnrollResponse)
|
||||||
|
def post_enroll(body: EnrollRequest, db: Session = Depends(get_db)) -> EnrollResponse:
|
||||||
|
try:
|
||||||
|
result = enroll_device(
|
||||||
|
db,
|
||||||
|
enrollment_code=body.enrollment_code,
|
||||||
|
username=body.username,
|
||||||
|
password=body.password,
|
||||||
|
device_uuid=body.device_uuid,
|
||||||
|
display_name=body.display_name,
|
||||||
|
platform=body.platform,
|
||||||
|
app_version=body.app_version,
|
||||||
|
fcm_token=body.fcm_token,
|
||||||
|
create_access_token=create_access_token,
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return EnrollResponse(
|
||||||
|
access_token=result.access_token,
|
||||||
|
refresh_token=result.refresh_token,
|
||||||
|
device_id=result.device_id,
|
||||||
|
username=result.username,
|
||||||
|
role=result.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth/refresh", response_model=EnrollResponse)
|
||||||
|
def post_refresh(body: RefreshRequest, db: Session = Depends(get_db)) -> EnrollResponse:
|
||||||
|
token_row = find_valid_refresh_token(db, body.refresh_token.strip())
|
||||||
|
if token_row is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||||
|
device = get_device_or_raise(db, token_row.device_id)
|
||||||
|
try:
|
||||||
|
assert_device_active(device)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
||||||
|
if device.device_uuid != body.device_uuid.strip():
|
||||||
|
raise HTTPException(status_code=401, detail="device_uuid mismatch")
|
||||||
|
|
||||||
|
user = db.get(User, device.user_id)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
raise HTTPException(status_code=401, detail="User inactive")
|
||||||
|
|
||||||
|
revoke_refresh_tokens_for_device(db, device.id)
|
||||||
|
refresh_plain = store_refresh_token(db, device_id=device.id)
|
||||||
|
touch_device(db, device)
|
||||||
|
access_token = create_access_token(user.username, user.role, device_id=device.id)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return EnrollResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_plain,
|
||||||
|
device_id=device.id,
|
||||||
|
username=user.username,
|
||||||
|
role=user.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/devices/me/fcm", status_code=204)
|
||||||
|
def put_my_fcm_token(
|
||||||
|
body: FcmTokenUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
if user.device_id is None:
|
||||||
|
raise HTTPException(status_code=403, detail="Mobile device token required")
|
||||||
|
device = get_device_or_raise(db, user.device_id)
|
||||||
|
try:
|
||||||
|
assert_device_active(device)
|
||||||
|
update_fcm_token(db, device, body.fcm_token)
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise HTTPException(status_code=401, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import auth, dashboards, events, health, hosts, problems, settings, stream, system, users
|
from app.api.v1 import auth, dashboards, events, health, hosts, mobile, problems, settings, stream, system, users
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(health.router)
|
api_router.include_router(health.router)
|
||||||
@@ -13,3 +13,4 @@ api_router.include_router(settings.router)
|
|||||||
api_router.include_router(system.router)
|
api_router.include_router(system.router)
|
||||||
api_router.include_router(users.router)
|
api_router.include_router(users.router)
|
||||||
api_router.include_router(stream.router)
|
api_router.include_router(stream.router)
|
||||||
|
api_router.include_router(mobile.router)
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ from app.services.notification_settings import (
|
|||||||
upsert_webhook_channel,
|
upsert_webhook_channel,
|
||||||
)
|
)
|
||||||
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message
|
from app.services.telegram_notify import TelegramNotConfiguredError, TelegramSendError, send_telegram_test_message
|
||||||
|
from app.services.webhook_notify import (
|
||||||
|
WebhookNotConfiguredError,
|
||||||
|
WebhookSendError,
|
||||||
|
send_webhook_test_message,
|
||||||
|
)
|
||||||
from app.services.event_severity_overrides import (
|
from app.services.event_severity_overrides import (
|
||||||
SOURCE_DB,
|
SOURCE_DB,
|
||||||
list_severity_override_items,
|
list_severity_override_items,
|
||||||
@@ -93,6 +98,7 @@ class NotificationPolicyResponse(BaseModel):
|
|||||||
use_telegram: bool
|
use_telegram: bool
|
||||||
use_webhook: bool
|
use_webhook: bool
|
||||||
use_email: bool
|
use_email: bool
|
||||||
|
use_mobile: bool
|
||||||
source: str = Field(description="env или db")
|
source: str = Field(description="env или db")
|
||||||
|
|
||||||
|
|
||||||
@@ -108,6 +114,7 @@ class NotificationPolicyUpdate(BaseModel):
|
|||||||
use_telegram: bool
|
use_telegram: bool
|
||||||
use_webhook: bool
|
use_webhook: bool
|
||||||
use_email: bool
|
use_email: bool
|
||||||
|
use_mobile: bool = False
|
||||||
|
|
||||||
|
|
||||||
class TelegramSettingsUpdate(BaseModel):
|
class TelegramSettingsUpdate(BaseModel):
|
||||||
@@ -146,6 +153,7 @@ def _policy_to_response(cfg: NotificationPolicyConfig) -> NotificationPolicyResp
|
|||||||
use_telegram=cfg.use_telegram,
|
use_telegram=cfg.use_telegram,
|
||||||
use_webhook=cfg.use_webhook,
|
use_webhook=cfg.use_webhook,
|
||||||
use_email=cfg.use_email,
|
use_email=cfg.use_email,
|
||||||
|
use_mobile=cfg.use_mobile,
|
||||||
source=cfg.source,
|
source=cfg.source,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -212,7 +220,7 @@ def update_notification_policy(
|
|||||||
) -> NotificationPolicyResponse:
|
) -> NotificationPolicyResponse:
|
||||||
if body.min_severity not in VALID_SEVERITIES:
|
if body.min_severity not in VALID_SEVERITIES:
|
||||||
raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}")
|
raise HTTPException(status_code=422, detail=f"min_severity must be one of: {sorted(VALID_SEVERITIES)}")
|
||||||
if not any((body.use_telegram, body.use_webhook, body.use_email)):
|
if not any((body.use_telegram, body.use_webhook, body.use_email, body.use_mobile)):
|
||||||
raise HTTPException(status_code=422, detail="Выберите хотя бы один канал")
|
raise HTTPException(status_code=422, detail="Выберите хотя бы один канал")
|
||||||
try:
|
try:
|
||||||
policy = upsert_notification_policy(
|
policy = upsert_notification_policy(
|
||||||
@@ -221,6 +229,7 @@ def update_notification_policy(
|
|||||||
use_telegram=body.use_telegram,
|
use_telegram=body.use_telegram,
|
||||||
use_webhook=body.use_webhook,
|
use_webhook=body.use_webhook,
|
||||||
use_email=body.use_email,
|
use_email=body.use_email,
|
||||||
|
use_mobile=body.use_mobile,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
|||||||
@@ -67,14 +67,9 @@ bearer_scheme = HTTPBearer(auto_error=False)
|
|||||||
|
|
||||||
|
|
||||||
class CurrentUser:
|
class CurrentUser:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
username: str
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
role: str
|
role: str
|
||||||
|
device_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -102,22 +97,12 @@ class CurrentUser:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(subject: str, role: str) -> str:
|
def create_access_token(subject: str, role: str, *, device_id: int | None = None) -> str:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
|
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
|
||||||
|
payload: dict[str, object] = {"sub": subject, "role": role, "exp": expire}
|
||||||
|
if device_id is not None:
|
||||||
|
payload["device_id"] = device_id
|
||||||
payload = {"sub": subject, "role": role, "exp": expire}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
|
|
||||||
@@ -130,6 +115,33 @@ def create_access_token(subject: str, role: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_mobile_device_active(db: Session, device_id: int, user_id: int) -> None:
|
||||||
|
from app.models.mobile_device import MobileDevice
|
||||||
|
|
||||||
|
device = db.get(MobileDevice, device_id)
|
||||||
|
if device is None or device.user_id != user_id or device.revoked_at is not None:
|
||||||
|
raise HTTPException(status_code=401, detail="Mobile device revoked or invalid")
|
||||||
|
|
||||||
|
|
||||||
|
def get_token_device_id(token: str) -> int | None:
|
||||||
|
settings = get_settings()
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.jwt_secret,
|
||||||
|
algorithms=[settings.jwt_algorithm],
|
||||||
|
)
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
raw = payload.get("device_id")
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _user_from_db(db: Session, username: str) -> User:
|
def _user_from_db(db: Session, username: str) -> User:
|
||||||
|
|
||||||
|
|
||||||
@@ -206,23 +218,22 @@ def _decode_current_user(token: str, db: Session | None = None) -> CurrentUser:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
parsed_device_id: int | None = None
|
||||||
|
raw_device_id = payload.get("device_id")
|
||||||
|
if raw_device_id is not None:
|
||||||
|
try:
|
||||||
|
parsed_device_id = int(raw_device_id)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token") from exc
|
||||||
|
|
||||||
if db is not None:
|
if db is not None:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
user = _user_from_db(db, str(username))
|
user = _user_from_db(db, str(username))
|
||||||
|
if parsed_device_id is not None:
|
||||||
|
_assert_mobile_device_active(db, parsed_device_id, user.id)
|
||||||
|
return CurrentUser(username=user.username, role=user.role, device_id=parsed_device_id)
|
||||||
return CurrentUser(username=user.username, role=user.role)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
role = str(payload.get("role") or USER_ROLE_ADMIN)
|
role = str(payload.get("role") or USER_ROLE_ADMIN)
|
||||||
|
return CurrentUser(username=str(username), role=role, device_id=parsed_device_id)
|
||||||
|
|
||||||
|
|
||||||
return CurrentUser(username=str(username), role=role)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,12 @@ class Settings(BaseSettings):
|
|||||||
sac_login_failure_window_minutes: int = 15
|
sac_login_failure_window_minutes: int = 15
|
||||||
sac_login_alert_telegram: bool = True
|
sac_login_alert_telegram: bool = True
|
||||||
|
|
||||||
|
# Seaca mobile (FCM push)
|
||||||
|
sac_fcm_enabled: bool = False
|
||||||
|
sac_fcm_project_id: str = ""
|
||||||
|
sac_fcm_service_account_json: str = ""
|
||||||
|
sac_mobile_refresh_expire_days: int = 90
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ from app.models.login_attempt import LoginAttempt
|
|||||||
from app.models.ui_settings import UiSettings
|
from app.models.ui_settings import UiSettings
|
||||||
from app.models.event_severity_override import EventSeverityOverride
|
from app.models.event_severity_override import EventSeverityOverride
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.models.mobile_settings import MobileSettings
|
||||||
|
from app.models.mobile_enrollment_code import MobileEnrollmentCode
|
||||||
|
from app.models.mobile_device import MobileDevice
|
||||||
|
from app.models.mobile_refresh_token import MobileRefreshToken
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ApiKey",
|
"ApiKey",
|
||||||
@@ -23,4 +27,8 @@ __all__ = [
|
|||||||
"EventSeverityOverride",
|
"EventSeverityOverride",
|
||||||
"LoginAttempt",
|
"LoginAttempt",
|
||||||
"UiSettings",
|
"UiSettings",
|
||||||
|
"MobileSettings",
|
||||||
|
"MobileEnrollmentCode",
|
||||||
|
"MobileDevice",
|
||||||
|
"MobileRefreshToken",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class MobileDevice(Base):
|
||||||
|
__tablename__ = "mobile_devices"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("sac_users.id", ondelete="CASCADE"), index=True)
|
||||||
|
device_uuid: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
platform: Mapped[str] = mapped_column(String(32), default="android")
|
||||||
|
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
fcm_token: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
fcm_token_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
enrollment_code_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("mobile_enrollment_codes.id", ondelete="SET NULL"),
|
||||||
|
)
|
||||||
|
enrolled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class MobileEnrollmentCode(Base):
|
||||||
|
__tablename__ = "mobile_enrollment_codes"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
label: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
code_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
code_prefix: Mapped[str] = mapped_column(String(16))
|
||||||
|
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("sac_users.id", ondelete="SET NULL"))
|
||||||
|
target_user_id: Mapped[int | None] = mapped_column(ForeignKey("sac_users.id", ondelete="SET NULL"))
|
||||||
|
login_mode: Mapped[str] = mapped_column(String(16), default="password")
|
||||||
|
max_uses: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
use_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class MobileRefreshToken(Base):
|
||||||
|
__tablename__ = "mobile_refresh_tokens"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
device_id: Mapped[int] = mapped_column(ForeignKey("mobile_devices.id", ondelete="CASCADE"))
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from sqlalchemy import Boolean, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
MOBILE_SETTINGS_ROW_ID = 1
|
||||||
|
|
||||||
|
LOGIN_MODE_PASSWORD = "password"
|
||||||
|
LOGIN_MODE_CODE_ONLY = "code_only"
|
||||||
|
LOGIN_MODES = frozenset({LOGIN_MODE_PASSWORD, LOGIN_MODE_CODE_ONLY})
|
||||||
|
|
||||||
|
|
||||||
|
class MobileSettings(Base):
|
||||||
|
"""Singleton: глобальные настройки мобильного клиента Seaca."""
|
||||||
|
|
||||||
|
__tablename__ = "mobile_settings"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
devices_allowed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
max_devices_per_user: Mapped[int] = mapped_column(Integer, default=3)
|
||||||
|
min_app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
@@ -16,3 +16,4 @@ class NotificationPolicy(Base):
|
|||||||
use_telegram: Mapped[bool] = mapped_column(Boolean, default=True)
|
use_telegram: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
use_webhook: Mapped[bool] = mapped_column(Boolean, default=False)
|
use_webhook: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
use_email: Mapped[bool] = mapped_column(Boolean, default=False)
|
use_email: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
use_mobile: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Управление зарегистрированными устройствами Seaca."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.mobile_device import MobileDevice
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.mobile_tokens import revoke_refresh_tokens_for_device
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MobileDeviceSummary:
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
device_uuid: str
|
||||||
|
display_name: str
|
||||||
|
platform: str
|
||||||
|
app_version: str | None
|
||||||
|
has_fcm_token: bool
|
||||||
|
enrolled_at: datetime
|
||||||
|
last_seen_at: datetime | None
|
||||||
|
revoked_at: datetime | None
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
def list_mobile_devices(db: Session) -> list[MobileDeviceSummary]:
|
||||||
|
rows = db.scalars(select(MobileDevice).order_by(MobileDevice.enrolled_at.desc())).all()
|
||||||
|
user_ids = {r.user_id for r in rows}
|
||||||
|
usernames: dict[int, str] = {}
|
||||||
|
if user_ids:
|
||||||
|
for user in db.scalars(select(User).where(User.id.in_(user_ids))).all():
|
||||||
|
usernames[user.id] = user.username
|
||||||
|
return [_to_summary(row, usernames.get(row.user_id, "?")) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _to_summary(row: MobileDevice, username: str) -> MobileDeviceSummary:
|
||||||
|
return MobileDeviceSummary(
|
||||||
|
id=row.id,
|
||||||
|
user_id=row.user_id,
|
||||||
|
username=username,
|
||||||
|
device_uuid=row.device_uuid,
|
||||||
|
display_name=row.display_name or "",
|
||||||
|
platform=row.platform or "android",
|
||||||
|
app_version=row.app_version,
|
||||||
|
has_fcm_token=bool((row.fcm_token or "").strip()),
|
||||||
|
enrolled_at=row.enrolled_at,
|
||||||
|
last_seen_at=row.last_seen_at,
|
||||||
|
revoked_at=row.revoked_at,
|
||||||
|
is_active=row.revoked_at is None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_device_or_raise(db: Session, device_id: int) -> MobileDevice:
|
||||||
|
row = db.get(MobileDevice, device_id)
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("device not found")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def assert_device_active(device: MobileDevice) -> None:
|
||||||
|
if device.revoked_at is not None:
|
||||||
|
raise PermissionError("device has been revoked")
|
||||||
|
|
||||||
|
|
||||||
|
def touch_device(db: Session, device: MobileDevice, *, commit: bool = False) -> None:
|
||||||
|
device.last_seen_at = datetime.now(timezone.utc)
|
||||||
|
if commit:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_device(db: Session, device_id: int) -> MobileDeviceSummary:
|
||||||
|
device = get_device_or_raise(db, device_id)
|
||||||
|
if device.revoked_at is None:
|
||||||
|
device.revoked_at = datetime.now(timezone.utc)
|
||||||
|
revoke_refresh_tokens_for_device(db, device.id)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(device)
|
||||||
|
user = db.get(User, device.user_id)
|
||||||
|
username = user.username if user else "?"
|
||||||
|
return _to_summary(device, username)
|
||||||
|
|
||||||
|
|
||||||
|
def update_fcm_token(db: Session, device: MobileDevice, fcm_token: str) -> None:
|
||||||
|
cleaned = (fcm_token or "").strip()
|
||||||
|
if not cleaned:
|
||||||
|
raise ValueError("fcm_token is required")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
device.fcm_token = cleaned
|
||||||
|
device.fcm_token_updated_at = now
|
||||||
|
device.last_seen_at = now
|
||||||
|
db.commit()
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""Создание и проверка кодов регистрации Seaca."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.mobile_device import MobileDevice
|
||||||
|
from app.models.mobile_enrollment_code import MobileEnrollmentCode
|
||||||
|
from app.models.mobile_settings import LOGIN_MODE_CODE_ONLY, LOGIN_MODE_PASSWORD, LOGIN_MODES
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.mobile_settings import get_effective_mobile_settings
|
||||||
|
from app.services.mobile_tokens import (
|
||||||
|
generate_enrollment_code,
|
||||||
|
hash_mobile_secret,
|
||||||
|
revoke_refresh_tokens_for_device,
|
||||||
|
store_refresh_token,
|
||||||
|
)
|
||||||
|
from app.services.user_auth import authenticate_user
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EnrollmentCodeSummary:
|
||||||
|
id: int
|
||||||
|
label: str
|
||||||
|
code_prefix: str
|
||||||
|
target_user_id: int | None
|
||||||
|
target_username: str | None
|
||||||
|
login_mode: str
|
||||||
|
max_uses: int
|
||||||
|
use_count: int
|
||||||
|
expires_at: datetime
|
||||||
|
revoked_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EnrollResult:
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
device_id: int
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
def _as_utc(dt: datetime) -> datetime:
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _code_is_usable(row: MobileEnrollmentCode, *, now: datetime) -> bool:
|
||||||
|
if row.revoked_at is not None:
|
||||||
|
return False
|
||||||
|
if _as_utc(row.expires_at) <= now:
|
||||||
|
return False
|
||||||
|
if row.use_count >= row.max_uses:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def list_enrollment_codes(db: Session) -> list[EnrollmentCodeSummary]:
|
||||||
|
rows = db.scalars(
|
||||||
|
select(MobileEnrollmentCode).order_by(MobileEnrollmentCode.created_at.desc())
|
||||||
|
).all()
|
||||||
|
usernames: dict[int, str] = {}
|
||||||
|
user_ids = {r.target_user_id for r in rows if r.target_user_id}
|
||||||
|
if user_ids:
|
||||||
|
for user in db.scalars(select(User).where(User.id.in_(user_ids))).all():
|
||||||
|
usernames[user.id] = user.username
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return [
|
||||||
|
EnrollmentCodeSummary(
|
||||||
|
id=row.id,
|
||||||
|
label=row.label or "",
|
||||||
|
code_prefix=row.code_prefix,
|
||||||
|
target_user_id=row.target_user_id,
|
||||||
|
target_username=usernames.get(row.target_user_id) if row.target_user_id else None,
|
||||||
|
login_mode=row.login_mode,
|
||||||
|
max_uses=row.max_uses,
|
||||||
|
use_count=row.use_count,
|
||||||
|
expires_at=row.expires_at,
|
||||||
|
revoked_at=row.revoked_at,
|
||||||
|
created_at=row.created_at,
|
||||||
|
is_active=_code_is_usable(row, now=now),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def create_enrollment_code(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
created_by_user_id: int | None,
|
||||||
|
label: str,
|
||||||
|
target_user_id: int | None,
|
||||||
|
login_mode: str,
|
||||||
|
max_uses: int,
|
||||||
|
expires_in_hours: int,
|
||||||
|
) -> tuple[EnrollmentCodeSummary, str]:
|
||||||
|
if login_mode not in LOGIN_MODES:
|
||||||
|
raise ValueError(f"login_mode must be one of: {sorted(LOGIN_MODES)}")
|
||||||
|
if login_mode == LOGIN_MODE_CODE_ONLY and target_user_id is None:
|
||||||
|
raise ValueError("code_only requires target_user_id")
|
||||||
|
if max_uses < 1 or max_uses > 100:
|
||||||
|
raise ValueError("max_uses must be between 1 and 100")
|
||||||
|
if expires_in_hours < 1 or expires_in_hours > 24 * 30:
|
||||||
|
raise ValueError("expires_in_hours must be between 1 and 720")
|
||||||
|
|
||||||
|
if target_user_id is not None:
|
||||||
|
user = db.get(User, target_user_id)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
raise ValueError("target user not found or inactive")
|
||||||
|
|
||||||
|
plaintext, prefix, code_hash = generate_enrollment_code()
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)
|
||||||
|
row = MobileEnrollmentCode(
|
||||||
|
label=(label or "").strip(),
|
||||||
|
code_hash=code_hash,
|
||||||
|
code_prefix=prefix,
|
||||||
|
created_by_user_id=created_by_user_id,
|
||||||
|
target_user_id=target_user_id,
|
||||||
|
login_mode=login_mode,
|
||||||
|
max_uses=max_uses,
|
||||||
|
use_count=0,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.flush()
|
||||||
|
target_username = None
|
||||||
|
if row.target_user_id:
|
||||||
|
u = db.get(User, row.target_user_id)
|
||||||
|
target_username = u.username if u else None
|
||||||
|
summary = EnrollmentCodeSummary(
|
||||||
|
id=row.id,
|
||||||
|
label=row.label or "",
|
||||||
|
code_prefix=row.code_prefix,
|
||||||
|
target_user_id=row.target_user_id,
|
||||||
|
target_username=target_username,
|
||||||
|
login_mode=row.login_mode,
|
||||||
|
max_uses=row.max_uses,
|
||||||
|
use_count=row.use_count,
|
||||||
|
expires_at=row.expires_at,
|
||||||
|
revoked_at=row.revoked_at,
|
||||||
|
created_at=row.created_at,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
return summary, plaintext
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_enrollment_code(db: Session, code_id: int) -> None:
|
||||||
|
row = db.get(MobileEnrollmentCode, code_id)
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("enrollment code not found")
|
||||||
|
if row.revoked_at is None:
|
||||||
|
row.revoked_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _count_active_devices(db: Session, user_id: int) -> int:
|
||||||
|
return (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(MobileDevice)
|
||||||
|
.where(MobileDevice.user_id == user_id, MobileDevice.revoked_at.is_(None))
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def enroll_device(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
enrollment_code: str,
|
||||||
|
username: str | None,
|
||||||
|
password: str | None,
|
||||||
|
device_uuid: str,
|
||||||
|
display_name: str,
|
||||||
|
platform: str,
|
||||||
|
app_version: str | None,
|
||||||
|
fcm_token: str | None,
|
||||||
|
create_access_token,
|
||||||
|
) -> EnrollResult:
|
||||||
|
from app.auth.jwt_auth import create_access_token as _create_access_token
|
||||||
|
|
||||||
|
token_factory = create_access_token or _create_access_token
|
||||||
|
|
||||||
|
mobile_cfg = get_effective_mobile_settings(db)
|
||||||
|
if not mobile_cfg.devices_allowed:
|
||||||
|
raise PermissionError("mobile enrollment is disabled")
|
||||||
|
|
||||||
|
cleaned_uuid = (device_uuid or "").strip()
|
||||||
|
if len(cleaned_uuid) < 8:
|
||||||
|
raise ValueError("device_uuid is required")
|
||||||
|
|
||||||
|
if mobile_cfg.min_app_version and app_version:
|
||||||
|
if _version_lt(app_version, mobile_cfg.min_app_version):
|
||||||
|
raise ValueError(f"app version {app_version} is below minimum {mobile_cfg.min_app_version}")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
code_hash = hash_mobile_secret(enrollment_code.strip())
|
||||||
|
code_row = db.scalar(
|
||||||
|
select(MobileEnrollmentCode).where(MobileEnrollmentCode.code_hash == code_hash)
|
||||||
|
)
|
||||||
|
if code_row is None or not _code_is_usable(code_row, now=now):
|
||||||
|
raise ValueError("invalid or expired enrollment code")
|
||||||
|
|
||||||
|
if code_row.login_mode == LOGIN_MODE_PASSWORD:
|
||||||
|
if not username or not password:
|
||||||
|
raise ValueError("username and password required")
|
||||||
|
user = authenticate_user(db, username, password)
|
||||||
|
if user is None:
|
||||||
|
raise ValueError("invalid username or password")
|
||||||
|
else:
|
||||||
|
user = db.get(User, code_row.target_user_id) if code_row.target_user_id else None
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
raise ValueError("enrollment code user is invalid")
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
active_count = _count_active_devices(db, user.id)
|
||||||
|
if active_count >= mobile_cfg.max_devices_per_user:
|
||||||
|
raise ValueError("device limit reached for this user")
|
||||||
|
|
||||||
|
existing = db.scalar(select(MobileDevice).where(MobileDevice.device_uuid == cleaned_uuid))
|
||||||
|
if existing is not None:
|
||||||
|
if existing.revoked_at is None and existing.user_id != user.id:
|
||||||
|
raise ValueError("device_uuid already registered to another user")
|
||||||
|
if existing.revoked_at is None:
|
||||||
|
revoke_refresh_tokens_for_device(db, existing.id)
|
||||||
|
existing.revoked_at = now
|
||||||
|
existing = None
|
||||||
|
|
||||||
|
device = MobileDevice(
|
||||||
|
user_id=user.id,
|
||||||
|
device_uuid=cleaned_uuid,
|
||||||
|
display_name=(display_name or "").strip() or "Android",
|
||||||
|
platform=(platform or "android").strip() or "android",
|
||||||
|
app_version=(app_version or "").strip() or None,
|
||||||
|
fcm_token=(fcm_token or "").strip() or None,
|
||||||
|
fcm_token_updated_at=now if fcm_token else None,
|
||||||
|
enrollment_code_id=code_row.id,
|
||||||
|
last_seen_at=now,
|
||||||
|
)
|
||||||
|
db.add(device)
|
||||||
|
code_row.use_count += 1
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
refresh_plain = store_refresh_token(db, device_id=device.id)
|
||||||
|
access_token = token_factory(user.username, user.role, device_id=device.id)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return EnrollResult(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_plain,
|
||||||
|
device_id=device.id,
|
||||||
|
username=user.username,
|
||||||
|
role=user.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _version_lt(current: str, minimum: str) -> bool:
|
||||||
|
def parse(v: str) -> tuple[int, ...]:
|
||||||
|
parts: list[int] = []
|
||||||
|
for piece in v.strip().split("."):
|
||||||
|
try:
|
||||||
|
parts.append(int(piece))
|
||||||
|
except ValueError:
|
||||||
|
parts.append(0)
|
||||||
|
return tuple(parts)
|
||||||
|
|
||||||
|
return parse(current) < parse(minimum)
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Push-уведомления Seaca через Firebase Cloud Messaging (HTTP v1)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models import Event, Problem
|
||||||
|
from app.models.mobile_device import MobileDevice
|
||||||
|
from app.services.notification_severity import severity_meets_minimum
|
||||||
|
from app.services.notification_policy import get_effective_notification_policy
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging"
|
||||||
|
|
||||||
|
|
||||||
|
class MobileNotConfiguredError(Exception):
|
||||||
|
"""FCM отключён или не задан service account."""
|
||||||
|
|
||||||
|
|
||||||
|
class MobileSendError(Exception):
|
||||||
|
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FcmConfig:
|
||||||
|
enabled: bool
|
||||||
|
project_id: str
|
||||||
|
service_account_path: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured(self) -> bool:
|
||||||
|
return bool(self.project_id.strip() and self.service_account_path.strip())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self) -> bool:
|
||||||
|
return self.enabled and self.configured
|
||||||
|
|
||||||
|
|
||||||
|
def get_effective_fcm_config() -> FcmConfig:
|
||||||
|
settings = get_settings()
|
||||||
|
return FcmConfig(
|
||||||
|
enabled=bool(settings.sac_fcm_enabled),
|
||||||
|
project_id=(settings.sac_fcm_project_id or "").strip(),
|
||||||
|
service_account_path=(settings.sac_fcm_service_account_json or "").strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_service_account(path: str) -> dict:
|
||||||
|
file_path = Path(path)
|
||||||
|
if not file_path.is_file():
|
||||||
|
raise MobileNotConfiguredError(f"FCM service account file not found: {path}")
|
||||||
|
with file_path.open(encoding="utf-8") as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
|
||||||
|
|
||||||
|
def _fcm_access_token(service_account_path: str) -> str:
|
||||||
|
try:
|
||||||
|
from google.oauth2 import service_account
|
||||||
|
from google.auth.transport.requests import Request
|
||||||
|
except ImportError as exc:
|
||||||
|
raise MobileNotConfiguredError("google-auth package is required for FCM") from exc
|
||||||
|
|
||||||
|
creds = service_account.Credentials.from_service_account_file(
|
||||||
|
service_account_path,
|
||||||
|
scopes=[FCM_SCOPE],
|
||||||
|
)
|
||||||
|
creds.refresh(Request())
|
||||||
|
if not creds.token:
|
||||||
|
raise MobileSendError("Failed to obtain FCM access token")
|
||||||
|
return creds.token
|
||||||
|
|
||||||
|
|
||||||
|
def _active_device_tokens(db: Session | None) -> list[str]:
|
||||||
|
if db is None:
|
||||||
|
return []
|
||||||
|
rows = db.scalars(
|
||||||
|
select(MobileDevice).where(
|
||||||
|
MobileDevice.revoked_at.is_(None),
|
||||||
|
MobileDevice.fcm_token.is_not(None),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
tokens: list[str] = []
|
||||||
|
for row in rows:
|
||||||
|
token = (row.fcm_token or "").strip()
|
||||||
|
if token:
|
||||||
|
tokens.append(token)
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
def _send_fcm_data(tokens: list[str], data: dict[str, str], *, title: str, body: str) -> None:
|
||||||
|
if not tokens:
|
||||||
|
return
|
||||||
|
cfg = get_effective_fcm_config()
|
||||||
|
if not cfg.active:
|
||||||
|
return
|
||||||
|
|
||||||
|
access_token = _fcm_access_token(cfg.service_account_path)
|
||||||
|
url = f"https://fcm.googleapis.com/v1/projects/{cfg.project_id}/messages:send"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
with httpx.Client(timeout=12.0) as client:
|
||||||
|
for token in tokens:
|
||||||
|
payload = {
|
||||||
|
"message": {
|
||||||
|
"token": token,
|
||||||
|
"notification": {"title": title, "body": body},
|
||||||
|
"data": data,
|
||||||
|
"android": {"priority": "high"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = client.post(url, headers=headers, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
detail = exc.response.text[:200] if exc.response is not None else str(exc)
|
||||||
|
logger.warning("FCM send failed for token prefix %s: %s", token[:8], detail)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("FCM send failed")
|
||||||
|
|
||||||
|
|
||||||
|
def _event_payload(event: Event) -> tuple[str, str, dict[str, str]]:
|
||||||
|
title = f"[{event.severity}] {event.title}"
|
||||||
|
body = (event.summary or "")[:200]
|
||||||
|
data = {
|
||||||
|
"kind": "event",
|
||||||
|
"id": str(event.id),
|
||||||
|
"severity": event.severity,
|
||||||
|
"type": event.type,
|
||||||
|
}
|
||||||
|
return title, body, data
|
||||||
|
|
||||||
|
|
||||||
|
def _problem_payload(problem: Problem) -> tuple[str, str, dict[str, str]]:
|
||||||
|
title = f"[{problem.severity}] {problem.title}"
|
||||||
|
body = (problem.summary or "")[:200]
|
||||||
|
data = {
|
||||||
|
"kind": "problem",
|
||||||
|
"id": str(problem.id),
|
||||||
|
"severity": problem.severity,
|
||||||
|
"status": problem.status,
|
||||||
|
}
|
||||||
|
return title, body, data
|
||||||
|
|
||||||
|
|
||||||
|
def notify_event(event: Event, *, db: Session | None = None, apply_policy_gate: bool = True) -> None:
|
||||||
|
policy = get_effective_notification_policy(db)
|
||||||
|
if apply_policy_gate and not severity_meets_minimum(event.severity, policy.min_severity):
|
||||||
|
return
|
||||||
|
tokens = _active_device_tokens(db)
|
||||||
|
if not tokens:
|
||||||
|
return
|
||||||
|
title, body, data = _event_payload(event)
|
||||||
|
_send_fcm_data(tokens, data, title=title, body=body)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_problem(
|
||||||
|
problem: Problem,
|
||||||
|
event: Event | None = None,
|
||||||
|
*,
|
||||||
|
db: Session | None = None,
|
||||||
|
apply_policy_gate: bool = True,
|
||||||
|
) -> None:
|
||||||
|
policy = get_effective_notification_policy(db)
|
||||||
|
if apply_policy_gate and not severity_meets_minimum(problem.severity, policy.min_severity):
|
||||||
|
return
|
||||||
|
tokens = _active_device_tokens(db)
|
||||||
|
if not tokens:
|
||||||
|
return
|
||||||
|
title, body, data = _problem_payload(problem)
|
||||||
|
_send_fcm_data(tokens, data, title=title, body=body)
|
||||||
|
|
||||||
|
|
||||||
|
def send_test_push(*, db: Session, device_id: int) -> None:
|
||||||
|
cfg = get_effective_fcm_config()
|
||||||
|
if not cfg.enabled:
|
||||||
|
raise MobileNotConfiguredError("FCM отключён (SAC_FCM_ENABLED=false)")
|
||||||
|
if not cfg.configured:
|
||||||
|
raise MobileNotConfiguredError("Не задан SAC_FCM_PROJECT_ID или SAC_FCM_SERVICE_ACCOUNT_JSON")
|
||||||
|
|
||||||
|
device = db.get(MobileDevice, device_id)
|
||||||
|
if device is None or device.revoked_at is not None:
|
||||||
|
raise ValueError("device not found or revoked")
|
||||||
|
token = (device.fcm_token or "").strip()
|
||||||
|
if not token:
|
||||||
|
raise ValueError("device has no FCM token")
|
||||||
|
|
||||||
|
_send_fcm_data(
|
||||||
|
[token],
|
||||||
|
{"kind": "test", "id": "0"},
|
||||||
|
title="SAC: тестовое push",
|
||||||
|
body="Канал Seaca (FCM) работает.",
|
||||||
|
)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.mobile_settings import MOBILE_SETTINGS_ROW_ID, MobileSettings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MobileSettingsConfig:
|
||||||
|
devices_allowed: bool
|
||||||
|
max_devices_per_user: int
|
||||||
|
min_app_version: str | None
|
||||||
|
source: str = "db"
|
||||||
|
|
||||||
|
|
||||||
|
def get_effective_mobile_settings(db: Session) -> MobileSettingsConfig:
|
||||||
|
row = db.get(MobileSettings, MOBILE_SETTINGS_ROW_ID)
|
||||||
|
if row is None:
|
||||||
|
return MobileSettingsConfig(
|
||||||
|
devices_allowed=False,
|
||||||
|
max_devices_per_user=3,
|
||||||
|
min_app_version=None,
|
||||||
|
source="default",
|
||||||
|
)
|
||||||
|
return MobileSettingsConfig(
|
||||||
|
devices_allowed=bool(row.devices_allowed),
|
||||||
|
max_devices_per_user=max(1, int(row.max_devices_per_user or 3)),
|
||||||
|
min_app_version=(row.min_app_version or "").strip() or None,
|
||||||
|
source="db",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_mobile_settings(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
devices_allowed: bool,
|
||||||
|
max_devices_per_user: int,
|
||||||
|
min_app_version: str | None,
|
||||||
|
) -> MobileSettingsConfig:
|
||||||
|
if max_devices_per_user < 1 or max_devices_per_user > 50:
|
||||||
|
raise ValueError("max_devices_per_user must be between 1 and 50")
|
||||||
|
cleaned_version = (min_app_version or "").strip() or None
|
||||||
|
row = db.get(MobileSettings, MOBILE_SETTINGS_ROW_ID)
|
||||||
|
if row is None:
|
||||||
|
row = MobileSettings(
|
||||||
|
id=MOBILE_SETTINGS_ROW_ID,
|
||||||
|
devices_allowed=devices_allowed,
|
||||||
|
max_devices_per_user=max_devices_per_user,
|
||||||
|
min_app_version=cleaned_version,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
else:
|
||||||
|
row.devices_allowed = devices_allowed
|
||||||
|
row.max_devices_per_user = max_devices_per_user
|
||||||
|
row.min_app_version = cleaned_version
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return get_effective_mobile_settings(db)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Хеширование enrollment/refresh токенов и генерация."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models.mobile_refresh_token import MobileRefreshToken
|
||||||
|
|
||||||
|
|
||||||
|
def hash_mobile_secret(raw: str) -> str:
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_enrollment_code() -> tuple[str, str, str]:
|
||||||
|
"""(plaintext, prefix, hash)"""
|
||||||
|
raw = f"sacmob_{secrets.token_urlsafe(18)}"
|
||||||
|
prefix = raw[:12]
|
||||||
|
return raw, prefix, hash_mobile_secret(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_refresh_token() -> tuple[str, str]:
|
||||||
|
"""(plaintext, hash)"""
|
||||||
|
raw = secrets.token_urlsafe(48)
|
||||||
|
return raw, hash_mobile_secret(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def store_refresh_token(db: Session, *, device_id: int) -> str:
|
||||||
|
settings = get_settings()
|
||||||
|
days = max(1, int(settings.sac_mobile_refresh_expire_days))
|
||||||
|
plaintext, token_hash = generate_refresh_token()
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(days=days)
|
||||||
|
row = MobileRefreshToken(
|
||||||
|
device_id=device_id,
|
||||||
|
token_hash=token_hash,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
return plaintext
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_refresh_tokens_for_device(db: Session, device_id: int) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
rows = db.scalars(
|
||||||
|
select(MobileRefreshToken).where(
|
||||||
|
MobileRefreshToken.device_id == device_id,
|
||||||
|
MobileRefreshToken.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for row in rows:
|
||||||
|
row.revoked_at = now
|
||||||
|
|
||||||
|
|
||||||
|
def find_valid_refresh_token(db: Session, raw_token: str) -> MobileRefreshToken | None:
|
||||||
|
token_hash = hash_mobile_secret(raw_token)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
return db.scalar(
|
||||||
|
select(MobileRefreshToken).where(
|
||||||
|
MobileRefreshToken.token_hash == token_hash,
|
||||||
|
MobileRefreshToken.revoked_at.is_(None),
|
||||||
|
MobileRefreshToken.expires_at > now,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -17,7 +17,8 @@ from app.services.notification_settings import (
|
|||||||
VALID_SEVERITIES,
|
VALID_SEVERITIES,
|
||||||
)
|
)
|
||||||
|
|
||||||
DEFAULT_CHANNELS = frozenset({CHANNEL_TELEGRAM, CHANNEL_WEBHOOK, CHANNEL_EMAIL})
|
CHANNEL_MOBILE = "mobile"
|
||||||
|
DEFAULT_CHANNELS = frozenset({CHANNEL_TELEGRAM, CHANNEL_WEBHOOK, CHANNEL_EMAIL, CHANNEL_MOBILE})
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -26,6 +27,7 @@ class NotificationPolicyConfig:
|
|||||||
use_telegram: bool
|
use_telegram: bool
|
||||||
use_webhook: bool
|
use_webhook: bool
|
||||||
use_email: bool
|
use_email: bool
|
||||||
|
use_mobile: bool
|
||||||
source: str # env | db
|
source: str # env | db
|
||||||
|
|
||||||
def allows_channel(self, channel: str) -> bool:
|
def allows_channel(self, channel: str) -> bool:
|
||||||
@@ -35,21 +37,24 @@ class NotificationPolicyConfig:
|
|||||||
return self.use_webhook
|
return self.use_webhook
|
||||||
if channel == CHANNEL_EMAIL:
|
if channel == CHANNEL_EMAIL:
|
||||||
return self.use_email
|
return self.use_email
|
||||||
|
if channel == CHANNEL_MOBILE:
|
||||||
|
return self.use_mobile
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _parse_channels_csv(value: str) -> tuple[bool, bool, bool]:
|
def _parse_channels_csv(value: str) -> tuple[bool, bool, bool, bool]:
|
||||||
parts = {p.strip().lower() for p in value.split(",") if p.strip()}
|
parts = {p.strip().lower() for p in value.split(",") if p.strip()}
|
||||||
return (
|
return (
|
||||||
CHANNEL_TELEGRAM in parts or "tg" in parts,
|
CHANNEL_TELEGRAM in parts or "tg" in parts,
|
||||||
CHANNEL_WEBHOOK in parts,
|
CHANNEL_WEBHOOK in parts,
|
||||||
CHANNEL_EMAIL in parts or "mail" in parts or "smtp" in parts,
|
CHANNEL_EMAIL in parts or "mail" in parts or "smtp" in parts,
|
||||||
|
CHANNEL_MOBILE in parts or "push" in parts or "fcm" in parts,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _policy_from_env() -> NotificationPolicyConfig:
|
def _policy_from_env() -> NotificationPolicyConfig:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
use_tg, use_wh, use_em = _parse_channels_csv(settings.notify_channels)
|
use_tg, use_wh, use_em, use_mob = _parse_channels_csv(settings.notify_channels)
|
||||||
min_sev = settings.notify_min_severity.strip() or "warning"
|
min_sev = settings.notify_min_severity.strip() or "warning"
|
||||||
if min_sev not in VALID_SEVERITIES:
|
if min_sev not in VALID_SEVERITIES:
|
||||||
min_sev = "warning"
|
min_sev = "warning"
|
||||||
@@ -58,6 +63,7 @@ def _policy_from_env() -> NotificationPolicyConfig:
|
|||||||
use_telegram=use_tg,
|
use_telegram=use_tg,
|
||||||
use_webhook=use_wh,
|
use_webhook=use_wh,
|
||||||
use_email=use_em,
|
use_email=use_em,
|
||||||
|
use_mobile=use_mob,
|
||||||
source="env",
|
source="env",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,6 +92,7 @@ def get_effective_notification_policy(db: Session | None = None) -> Notification
|
|||||||
use_telegram=bool(row.use_telegram),
|
use_telegram=bool(row.use_telegram),
|
||||||
use_webhook=bool(row.use_webhook),
|
use_webhook=bool(row.use_webhook),
|
||||||
use_email=bool(row.use_email),
|
use_email=bool(row.use_email),
|
||||||
|
use_mobile=bool(getattr(row, "use_mobile", False)),
|
||||||
source="db",
|
source="db",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -104,6 +111,7 @@ def upsert_notification_policy(
|
|||||||
use_telegram: bool,
|
use_telegram: bool,
|
||||||
use_webhook: bool,
|
use_webhook: bool,
|
||||||
use_email: bool,
|
use_email: bool,
|
||||||
|
use_mobile: bool = False,
|
||||||
) -> NotificationPolicyConfig:
|
) -> NotificationPolicyConfig:
|
||||||
if min_severity not in VALID_SEVERITIES:
|
if min_severity not in VALID_SEVERITIES:
|
||||||
raise ValueError(f"invalid min_severity: {min_severity}")
|
raise ValueError(f"invalid min_severity: {min_severity}")
|
||||||
@@ -116,6 +124,7 @@ def upsert_notification_policy(
|
|||||||
use_telegram=use_telegram,
|
use_telegram=use_telegram,
|
||||||
use_webhook=use_webhook,
|
use_webhook=use_webhook,
|
||||||
use_email=use_email,
|
use_email=use_email,
|
||||||
|
use_mobile=use_mobile,
|
||||||
)
|
)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
else:
|
else:
|
||||||
@@ -123,6 +132,7 @@ def upsert_notification_policy(
|
|||||||
row.use_telegram = use_telegram
|
row.use_telegram = use_telegram
|
||||||
row.use_webhook = use_webhook
|
row.use_webhook = use_webhook
|
||||||
row.use_email = use_email
|
row.use_email = use_email
|
||||||
|
row.use_mobile = use_mobile
|
||||||
|
|
||||||
_sync_channel_min_severity(db, min_severity)
|
_sync_channel_min_severity(db, min_severity)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import logging
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Event, Problem
|
from app.models import Event, Problem
|
||||||
from app.services import email_notify, telegram_notify, webhook_notify
|
from app.services import email_notify, mobile_notify, telegram_notify, webhook_notify
|
||||||
from app.services.notification_cooldown import should_notify_event, should_notify_problem
|
from app.services.notification_cooldown import should_notify_event, should_notify_problem
|
||||||
from app.services.notification_policy import get_effective_notification_policy
|
from app.services.notification_policy import get_effective_notification_policy
|
||||||
from app.services.notification_severity import severity_meets_minimum
|
from app.services.notification_severity import severity_meets_minimum
|
||||||
@@ -35,6 +35,8 @@ def _dispatch_event_channels(event: Event, *, db: Session | None, policy) -> Non
|
|||||||
webhook_notify.notify_event(event, db=db, apply_policy_gate=False)
|
webhook_notify.notify_event(event, db=db, apply_policy_gate=False)
|
||||||
if policy.use_email:
|
if policy.use_email:
|
||||||
email_notify.notify_event(event, db=db, apply_policy_gate=False)
|
email_notify.notify_event(event, db=db, apply_policy_gate=False)
|
||||||
|
if policy.use_mobile:
|
||||||
|
mobile_notify.notify_event(event, db=db, apply_policy_gate=False)
|
||||||
|
|
||||||
|
|
||||||
def _dispatch_problem_channels(problem: Problem, event: Event | None, *, db: Session | None, policy) -> None:
|
def _dispatch_problem_channels(problem: Problem, event: Event | None, *, db: Session | None, policy) -> None:
|
||||||
@@ -44,6 +46,8 @@ def _dispatch_problem_channels(problem: Problem, event: Event | None, *, db: Ses
|
|||||||
webhook_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
|
webhook_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
|
||||||
if policy.use_email:
|
if policy.use_email:
|
||||||
email_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
|
email_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
|
||||||
|
if policy.use_mobile:
|
||||||
|
mobile_notify.notify_problem(problem, event, db=db, apply_policy_gate=False)
|
||||||
|
|
||||||
|
|
||||||
def notify_event(event: Event, *, db: Session | None = None) -> None:
|
def notify_event(event: Event, *, db: Session | None = None) -> None:
|
||||||
@@ -72,6 +76,8 @@ def _dispatch_lifecycle_channels(event: Event, *, db: Session | None, policy) ->
|
|||||||
webhook_notify.notify_event(event, db=db, apply_policy_gate=False)
|
webhook_notify.notify_event(event, db=db, apply_policy_gate=False)
|
||||||
if policy.use_email:
|
if policy.use_email:
|
||||||
email_notify.notify_event(event, db=db, apply_policy_gate=False)
|
email_notify.notify_event(event, db=db, apply_policy_gate=False)
|
||||||
|
if policy.use_mobile:
|
||||||
|
mobile_notify.notify_event(event, db=db, apply_policy_gate=False)
|
||||||
|
|
||||||
|
|
||||||
def notify_daily_report(event: Event, *, db: Session | None = None) -> None:
|
def notify_daily_report(event: Event, *, db: Session | None = None) -> None:
|
||||||
|
|||||||
@@ -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.8.3"
|
APP_VERSION = "0.9.0"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ jsonschema>=4.23.0,<5
|
|||||||
passlib[bcrypt]>=1.7.4,<2
|
passlib[bcrypt]>=1.7.4,<2
|
||||||
python-jose[cryptography]>=3.3.0,<4
|
python-jose[cryptography]>=3.3.0,<4
|
||||||
httpx>=0.28.0,<1
|
httpx>=0.28.0,<1
|
||||||
|
google-auth>=2.36.0,<3
|
||||||
psutil>=6.1.0,<8
|
psutil>=6.1.0,<8
|
||||||
|
|
||||||
# dev
|
# dev
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ def test_notify_daily_report_bypasses_min_severity():
|
|||||||
use_telegram=True,
|
use_telegram=True,
|
||||||
use_webhook=False,
|
use_webhook=False,
|
||||||
use_email=False,
|
use_email=False,
|
||||||
|
use_mobile=False,
|
||||||
source="db",
|
source="db",
|
||||||
)
|
)
|
||||||
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
|
|||||||
|
|
||||||
|
|
||||||
def test_version_constants():
|
def test_version_constants():
|
||||||
assert APP_VERSION == "0.8.3"
|
assert APP_VERSION == "0.9.0"
|
||||||
assert APP_NAME == "Security Alert Center"
|
assert APP_NAME == "Security Alert Center"
|
||||||
assert APP_VERSION_LABEL == "Security Alert Center v.0.8.3"
|
assert APP_VERSION_LABEL == "Security Alert Center v.0.9.0"
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Mobile / Seaca API."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.models.mobile_device import MobileDevice
|
||||||
|
from app.models.mobile_enrollment_code import MobileEnrollmentCode
|
||||||
|
from app.models.mobile_settings import MOBILE_SETTINGS_ROW_ID, MobileSettings
|
||||||
|
from app.services.mobile_tokens import hash_mobile_secret
|
||||||
|
|
||||||
|
|
||||||
|
def _enable_mobile(db_session):
|
||||||
|
row = db_session.get(MobileSettings, MOBILE_SETTINGS_ROW_ID)
|
||||||
|
if row is None:
|
||||||
|
row = MobileSettings(id=MOBILE_SETTINGS_ROW_ID, devices_allowed=True, max_devices_per_user=3)
|
||||||
|
db_session.add(row)
|
||||||
|
else:
|
||||||
|
row.devices_allowed = True
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_settings_admin(jwt_headers, client, db_session):
|
||||||
|
_enable_mobile(db_session)
|
||||||
|
response = client.get("/api/v1/mobile/settings", headers=jwt_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["devices_allowed"] is True
|
||||||
|
assert body["max_devices_per_user"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_enrollment_code_and_enroll(jwt_headers, client, db_session):
|
||||||
|
_enable_mobile(db_session)
|
||||||
|
create = client.post(
|
||||||
|
"/api/v1/mobile/enrollment-codes",
|
||||||
|
headers=jwt_headers,
|
||||||
|
json={"label": "test phone", "login_mode": "password", "expires_in_hours": 1},
|
||||||
|
)
|
||||||
|
assert create.status_code == 200
|
||||||
|
created = create.json()
|
||||||
|
assert created["enrollment_code"].startswith("sacmob_")
|
||||||
|
assert created["code_prefix"]
|
||||||
|
|
||||||
|
enroll = client.post(
|
||||||
|
"/api/v1/mobile/enroll",
|
||||||
|
json={
|
||||||
|
"enrollment_code": created["enrollment_code"],
|
||||||
|
"username": "test-monitor",
|
||||||
|
"password": "test-monitor-password",
|
||||||
|
"device_uuid": "uuid-test-device-001",
|
||||||
|
"display_name": "Pixel Test",
|
||||||
|
"fcm_token": "fcm-token-test",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert enroll.status_code == 200
|
||||||
|
body = enroll.json()
|
||||||
|
assert body["username"] == "test-monitor"
|
||||||
|
assert body["role"] == "monitor"
|
||||||
|
assert body["device_id"] > 0
|
||||||
|
assert body["access_token"]
|
||||||
|
assert body["refresh_token"]
|
||||||
|
|
||||||
|
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"] == "Pixel Test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_enroll_blocked_when_disabled(client, db_session, jwt_headers):
|
||||||
|
code_plain = "sacmob_testcode123456"
|
||||||
|
db_session.add(
|
||||||
|
MobileEnrollmentCode(
|
||||||
|
label="x",
|
||||||
|
code_hash=hash_mobile_secret(code_plain),
|
||||||
|
code_prefix=code_plain[:12],
|
||||||
|
login_mode="password",
|
||||||
|
max_uses=1,
|
||||||
|
use_count=0,
|
||||||
|
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/mobile/enroll",
|
||||||
|
json={
|
||||||
|
"enrollment_code": code_plain,
|
||||||
|
"username": "test-monitor",
|
||||||
|
"password": "test-monitor-password",
|
||||||
|
"device_uuid": "uuid-disabled-001",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_token_rotation(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-admin",
|
||||||
|
"password": "test-admin-password",
|
||||||
|
"device_uuid": "uuid-refresh-001",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
data = enroll.json()
|
||||||
|
refresh = client.post(
|
||||||
|
"/api/v1/mobile/auth/refresh",
|
||||||
|
json={"refresh_token": data["refresh_token"], "device_uuid": "uuid-refresh-001"},
|
||||||
|
)
|
||||||
|
assert refresh.status_code == 200
|
||||||
|
refreshed = refresh.json()
|
||||||
|
assert refreshed["refresh_token"] != data["refresh_token"]
|
||||||
|
assert refreshed["device_id"] == data["device_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_device_blocks_token(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-revoke-001",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
data = enroll.json()
|
||||||
|
headers = {"Authorization": f"Bearer {data['access_token']}"}
|
||||||
|
|
||||||
|
revoke = client.delete(f"/api/v1/mobile/devices/{data['device_id']}", headers=jwt_headers)
|
||||||
|
assert revoke.status_code == 200
|
||||||
|
|
||||||
|
me = client.put(
|
||||||
|
"/api/v1/mobile/devices/me/fcm",
|
||||||
|
headers=headers,
|
||||||
|
json={"fcm_token": "new-token"},
|
||||||
|
)
|
||||||
|
assert me.status_code == 401
|
||||||
@@ -25,6 +25,7 @@ def test_put_policy_persists_db(jwt_headers, client, db_session, monkeypatch):
|
|||||||
"use_telegram": True,
|
"use_telegram": True,
|
||||||
"use_webhook": True,
|
"use_webhook": True,
|
||||||
"use_email": False,
|
"use_email": False,
|
||||||
|
"use_mobile": False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -58,6 +59,7 @@ def test_upsert_policy_syncs_channel_min_severity(db_session, monkeypatch):
|
|||||||
use_telegram=True,
|
use_telegram=True,
|
||||||
use_webhook=False,
|
use_webhook=False,
|
||||||
use_email=False,
|
use_email=False,
|
||||||
|
use_mobile=False,
|
||||||
)
|
)
|
||||||
row = db_session.get(NotificationChannel, "telegram")
|
row = db_session.get(NotificationChannel, "telegram")
|
||||||
assert row.min_severity == "critical"
|
assert row.min_severity == "critical"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ def test_notify_event_calls_selected_channels():
|
|||||||
use_telegram=True,
|
use_telegram=True,
|
||||||
use_webhook=False,
|
use_webhook=False,
|
||||||
use_email=False,
|
use_email=False,
|
||||||
|
use_mobile=False,
|
||||||
source="db",
|
source="db",
|
||||||
)
|
)
|
||||||
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
||||||
@@ -50,6 +51,7 @@ def test_notify_event_calls_selected_channels():
|
|||||||
use_telegram=True,
|
use_telegram=True,
|
||||||
use_webhook=True,
|
use_webhook=True,
|
||||||
use_email=False,
|
use_email=False,
|
||||||
|
use_mobile=False,
|
||||||
source="db",
|
source="db",
|
||||||
)
|
)
|
||||||
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
||||||
@@ -80,6 +82,7 @@ def test_notify_event_skipped_by_cooldown():
|
|||||||
use_telegram=True,
|
use_telegram=True,
|
||||||
use_webhook=False,
|
use_webhook=False,
|
||||||
use_email=False,
|
use_email=False,
|
||||||
|
use_mobile=False,
|
||||||
source="db",
|
source="db",
|
||||||
)
|
)
|
||||||
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
||||||
@@ -107,6 +110,7 @@ def test_notify_auth_login_bypasses_min_severity():
|
|||||||
use_telegram=True,
|
use_telegram=True,
|
||||||
use_webhook=False,
|
use_webhook=False,
|
||||||
use_email=False,
|
use_email=False,
|
||||||
|
use_mobile=False,
|
||||||
source="db",
|
source="db",
|
||||||
)
|
)
|
||||||
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
||||||
@@ -134,6 +138,7 @@ def test_notify_rdg_connection_bypasses_min_severity():
|
|||||||
use_telegram=True,
|
use_telegram=True,
|
||||||
use_webhook=False,
|
use_webhook=False,
|
||||||
use_email=False,
|
use_email=False,
|
||||||
|
use_mobile=False,
|
||||||
source="db",
|
source="db",
|
||||||
)
|
)
|
||||||
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
||||||
@@ -161,6 +166,7 @@ def test_notify_auth_login_skips_telegram_when_via_agent():
|
|||||||
use_telegram=True,
|
use_telegram=True,
|
||||||
use_webhook=True,
|
use_webhook=True,
|
||||||
use_email=False,
|
use_email=False,
|
||||||
|
use_mobile=False,
|
||||||
source="db",
|
source="db",
|
||||||
)
|
)
|
||||||
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
||||||
@@ -190,6 +196,7 @@ def test_notify_lifecycle_bypasses_min_severity():
|
|||||||
use_telegram=True,
|
use_telegram=True,
|
||||||
use_webhook=False,
|
use_webhook=False,
|
||||||
use_email=False,
|
use_email=False,
|
||||||
|
use_mobile=False,
|
||||||
source="db",
|
source="db",
|
||||||
)
|
)
|
||||||
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
||||||
@@ -217,6 +224,7 @@ def test_notify_lifecycle_skips_telegram_when_via_agent():
|
|||||||
use_telegram=True,
|
use_telegram=True,
|
||||||
use_webhook=True,
|
use_webhook=True,
|
||||||
use_email=False,
|
use_email=False,
|
||||||
|
use_mobile=False,
|
||||||
source="db",
|
source="db",
|
||||||
)
|
)
|
||||||
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
||||||
@@ -246,6 +254,7 @@ def test_notify_daily_report_skips_telegram_when_via_agent():
|
|||||||
use_telegram=True,
|
use_telegram=True,
|
||||||
use_webhook=True,
|
use_webhook=True,
|
||||||
use_email=False,
|
use_email=False,
|
||||||
|
use_mobile=False,
|
||||||
source="db",
|
source="db",
|
||||||
)
|
)
|
||||||
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
||||||
@@ -275,6 +284,7 @@ def test_notify_daily_report_skips_telegram_when_via_agent():
|
|||||||
use_telegram=True,
|
use_telegram=True,
|
||||||
use_webhook=True,
|
use_webhook=True,
|
||||||
use_email=False,
|
use_email=False,
|
||||||
|
use_mobile=False,
|
||||||
source="db",
|
source="db",
|
||||||
)
|
)
|
||||||
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
with patch.object(notify_dispatch, "get_effective_notification_policy", return_value=policy):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import uuid
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from app.models import Host, Problem
|
from app.models import Host, Problem
|
||||||
|
from tests.test_ingest import VALID_EVENT
|
||||||
|
|
||||||
|
|
||||||
def _event_payload(**overrides):
|
def _event_payload(**overrides):
|
||||||
|
|||||||
@@ -87,4 +87,11 @@ SAC_LOGIN_MAX_FAILURES=3
|
|||||||
SAC_LOGIN_FAILURE_WINDOW_MINUTES=15
|
SAC_LOGIN_FAILURE_WINDOW_MINUTES=15
|
||||||
SAC_LOGIN_ALERT_TELEGRAM=true
|
SAC_LOGIN_ALERT_TELEGRAM=true
|
||||||
|
|
||||||
|
# Seaca mobile push (FCM HTTP v1)
|
||||||
|
SAC_FCM_ENABLED=false
|
||||||
|
SAC_FCM_PROJECT_ID=
|
||||||
|
# Путь к JSON service account Firebase (chmod 600, владелец sac)
|
||||||
|
SAC_FCM_SERVICE_ACCOUNT_JSON=
|
||||||
|
SAC_MOBILE_REFRESH_EXPIRE_DAYS=90
|
||||||
|
|
||||||
CORS_ORIGINS=*
|
CORS_ORIGINS=*
|
||||||
|
|||||||
@@ -289,6 +289,97 @@ export interface TopTypeItem {
|
|||||||
count: number;
|
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 {
|
export interface DashboardSummary {
|
||||||
events_last_24h: number;
|
events_last_24h: number;
|
||||||
hosts_total: number;
|
hosts_total: number;
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export const APP_NAME = "Security Alert Center";
|
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}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -62,6 +62,10 @@
|
|||||||
<input v-model="policyForm.use_email" type="checkbox" />
|
<input v-model="policyForm.use_email" type="checkbox" />
|
||||||
Email
|
Email
|
||||||
</label>
|
</label>
|
||||||
|
<label class="settings-inline">
|
||||||
|
<input v-model="policyForm.use_mobile" type="checkbox" />
|
||||||
|
Seaca (push)
|
||||||
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<p v-if="policyLoaded" class="settings-meta">
|
<p v-if="policyLoaded" class="settings-meta">
|
||||||
Источник: <code>{{ policyLoaded.source }}</code>
|
Источник: <code>{{ policyLoaded.source }}</code>
|
||||||
@@ -74,6 +78,144 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</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">
|
<section class="card settings-card">
|
||||||
<h2>Severity по типу события</h2>
|
<h2>Severity по типу события</h2>
|
||||||
<p class="settings-hint settings-hint-top">
|
<p class="settings-hint settings-hint-top">
|
||||||
@@ -268,7 +410,22 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from "vue";
|
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 {
|
interface TelegramSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -309,6 +466,7 @@ interface NotificationPolicy {
|
|||||||
use_telegram: boolean;
|
use_telegram: boolean;
|
||||||
use_webhook: boolean;
|
use_webhook: boolean;
|
||||||
use_email: boolean;
|
use_email: boolean;
|
||||||
|
use_mobile: boolean;
|
||||||
source: string;
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,6 +492,8 @@ const savingTelegram = ref(false);
|
|||||||
const savingWebhook = ref(false);
|
const savingWebhook = ref(false);
|
||||||
const savingEmail = ref(false);
|
const savingEmail = ref(false);
|
||||||
const savingSeverity = ref(false);
|
const savingSeverity = ref(false);
|
||||||
|
const savingMobile = ref(false);
|
||||||
|
const creatingCode = ref(false);
|
||||||
const testingTelegram = ref(false);
|
const testingTelegram = ref(false);
|
||||||
const testingWebhook = ref(false);
|
const testingWebhook = ref(false);
|
||||||
const testingEmail = ref(false);
|
const testingEmail = ref(false);
|
||||||
@@ -346,12 +506,32 @@ const policyLoaded = ref<NotificationPolicy | null>(null);
|
|||||||
const uiLoaded = ref<UiSettings | null>(null);
|
const uiLoaded = ref<UiSettings | null>(null);
|
||||||
const severityRows = ref<SeverityOverrideRowUi[]>([]);
|
const severityRows = ref<SeverityOverrideRowUi[]>([]);
|
||||||
const severityFilter = ref("");
|
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({
|
const policyForm = reactive({
|
||||||
min_severity: "warning",
|
min_severity: "warning",
|
||||||
use_telegram: true,
|
use_telegram: true,
|
||||||
use_webhook: false,
|
use_webhook: false,
|
||||||
use_email: 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({
|
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() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
@@ -501,6 +769,7 @@ async function load() {
|
|||||||
policyForm.use_telegram = data.policy.use_telegram;
|
policyForm.use_telegram = data.policy.use_telegram;
|
||||||
policyForm.use_webhook = data.policy.use_webhook;
|
policyForm.use_webhook = data.policy.use_webhook;
|
||||||
policyForm.use_email = data.policy.use_email;
|
policyForm.use_email = data.policy.use_email;
|
||||||
|
policyForm.use_mobile = data.policy.use_mobile ?? false;
|
||||||
telegramLoaded.value = data.telegram;
|
telegramLoaded.value = data.telegram;
|
||||||
webhookLoaded.value = data.webhook;
|
webhookLoaded.value = data.webhook;
|
||||||
emailLoaded.value = data.email;
|
emailLoaded.value = data.email;
|
||||||
@@ -522,6 +791,7 @@ async function load() {
|
|||||||
emailForm.smtp_ssl = data.email.smtp_ssl;
|
emailForm.smtp_ssl = data.email.smtp_ssl;
|
||||||
await loadUiSettings();
|
await loadUiSettings();
|
||||||
await loadSeverityOverrides();
|
await loadSeverityOverrides();
|
||||||
|
await loadMobileSection();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
|
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -541,6 +811,7 @@ async function savePolicy() {
|
|||||||
use_telegram: policyForm.use_telegram,
|
use_telegram: policyForm.use_telegram,
|
||||||
use_webhook: policyForm.use_webhook,
|
use_webhook: policyForm.use_webhook,
|
||||||
use_email: policyForm.use_email,
|
use_email: policyForm.use_email,
|
||||||
|
use_mobile: policyForm.use_mobile,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
success.value = "Правило оповещений сохранено.";
|
success.value = "Правило оповещений сохранено.";
|
||||||
@@ -816,4 +1087,27 @@ onMounted(load);
|
|||||||
.settings-severity-table select {
|
.settings-severity-table select {
|
||||||
min-width: 11rem;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user