feat: multi-user UI login with admin and monitor roles
Add sac_users table, JWT roles, settings/users API guarded for admin, Users page in UI, and sac_manage_user.py CLI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
"""sac_users table for multi-user UI login
|
||||||
|
|
||||||
|
Revision ID: 009
|
||||||
|
Revises: 008
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "009"
|
||||||
|
down_revision: Union[str, None] = "008"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"sac_users",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("username", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("password_hash", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("role", sa.String(length=16), nullable=False, server_default="monitor"),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("username"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_sac_users_username", "sac_users", ["username"], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_sac_users_username", table_name="sac_users")
|
||||||
|
op.drop_table("sac_users")
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth.jwt_auth import create_access_token
|
from app.auth.jwt_auth import CurrentUser, create_access_token, get_current_user
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.services.user_auth import authenticate_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -15,17 +18,47 @@ class LoginRequest(BaseModel):
|
|||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
class MeResponse(BaseModel):
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
def login(body: LoginRequest) -> TokenResponse:
|
def login(body: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if not settings.sac_admin_password:
|
if not settings.sac_admin_password and not _has_any_user(db):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
detail="SAC_ADMIN_PASSWORD is not configured",
|
detail="SAC UI users are not configured (run alembic upgrade and set SAC_ADMIN_PASSWORD for bootstrap)",
|
||||||
)
|
)
|
||||||
if body.username != settings.sac_admin_username or body.password != settings.sac_admin_password:
|
user = authenticate_user(db, body.username, body.password)
|
||||||
|
if user is None:
|
||||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
token = create_access_token(body.username)
|
token = create_access_token(user.username, user.role)
|
||||||
return TokenResponse(access_token=token)
|
return TokenResponse(
|
||||||
|
access_token=token,
|
||||||
|
username=user.username,
|
||||||
|
role=user.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=MeResponse)
|
||||||
|
def me(current_user: CurrentUser = Depends(get_current_user)) -> MeResponse:
|
||||||
|
return MeResponse(username=current_user.username, role=current_user.role)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_any_user(db: Session) -> bool:
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
try:
|
||||||
|
count = db.scalar(select(func.count()).select_from(User)) or 0
|
||||||
|
return count > 0
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
return False
|
||||||
|
|||||||
@@ -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
|
from app.api.v1 import auth, dashboards, events, health, hosts, problems, settings, stream, users
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(health.router)
|
api_router.include_router(health.router)
|
||||||
@@ -10,4 +10,5 @@ api_router.include_router(hosts.router)
|
|||||||
api_router.include_router(problems.router)
|
api_router.include_router(problems.router)
|
||||||
api_router.include_router(dashboards.router)
|
api_router.include_router(dashboards.router)
|
||||||
api_router.include_router(settings.router)
|
api_router.include_router(settings.router)
|
||||||
|
api_router.include_router(users.router)
|
||||||
api_router.include_router(stream.router)
|
api_router.include_router(stream.router)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth.jwt_auth import get_current_user
|
from app.auth.jwt_auth import require_admin
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.services.email_notify import EmailNotConfiguredError, EmailSendError, send_email_test_message
|
from app.services.email_notify import EmailNotConfiguredError, EmailSendError, send_email_test_message
|
||||||
from app.services.notification_policy import (
|
from app.services.notification_policy import (
|
||||||
@@ -185,7 +185,7 @@ def _email_to_response(cfg: EmailConfig, policy: NotificationPolicyConfig) -> Em
|
|||||||
@router.get("/notifications", response_model=NotificationSettingsResponse)
|
@router.get("/notifications", response_model=NotificationSettingsResponse)
|
||||||
def get_notification_settings(
|
def get_notification_settings(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: str = Depends(get_current_user),
|
_user=Depends(require_admin),
|
||||||
) -> NotificationSettingsResponse:
|
) -> NotificationSettingsResponse:
|
||||||
policy = get_effective_notification_policy(db)
|
policy = get_effective_notification_policy(db)
|
||||||
return NotificationSettingsResponse(
|
return NotificationSettingsResponse(
|
||||||
@@ -200,7 +200,7 @@ def get_notification_settings(
|
|||||||
def update_notification_policy(
|
def update_notification_policy(
|
||||||
body: NotificationPolicyUpdate,
|
body: NotificationPolicyUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: str = Depends(get_current_user),
|
_user=Depends(require_admin),
|
||||||
) -> 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)}")
|
||||||
@@ -223,7 +223,7 @@ def update_notification_policy(
|
|||||||
def update_telegram_settings(
|
def update_telegram_settings(
|
||||||
body: TelegramSettingsUpdate,
|
body: TelegramSettingsUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: str = Depends(get_current_user),
|
_user=Depends(require_admin),
|
||||||
) -> TelegramSettingsResponse:
|
) -> TelegramSettingsResponse:
|
||||||
try:
|
try:
|
||||||
cfg = upsert_telegram_channel(
|
cfg = upsert_telegram_channel(
|
||||||
@@ -242,7 +242,7 @@ def update_telegram_settings(
|
|||||||
def update_webhook_settings(
|
def update_webhook_settings(
|
||||||
body: WebhookSettingsUpdate,
|
body: WebhookSettingsUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: str = Depends(get_current_user),
|
_user=Depends(require_admin),
|
||||||
) -> WebhookSettingsResponse:
|
) -> WebhookSettingsResponse:
|
||||||
try:
|
try:
|
||||||
cfg = upsert_webhook_channel(
|
cfg = upsert_webhook_channel(
|
||||||
@@ -262,7 +262,7 @@ def update_webhook_settings(
|
|||||||
def update_email_settings(
|
def update_email_settings(
|
||||||
body: EmailSettingsUpdate,
|
body: EmailSettingsUpdate,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: str = Depends(get_current_user),
|
_user=Depends(require_admin),
|
||||||
) -> EmailSettingsResponse:
|
) -> EmailSettingsResponse:
|
||||||
try:
|
try:
|
||||||
cfg = upsert_email_channel(
|
cfg = upsert_email_channel(
|
||||||
@@ -286,7 +286,7 @@ def update_email_settings(
|
|||||||
@router.post("/notifications/telegram/test", response_model=ChannelTestResponse)
|
@router.post("/notifications/telegram/test", response_model=ChannelTestResponse)
|
||||||
def test_telegram_settings(
|
def test_telegram_settings(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: str = Depends(get_current_user),
|
_user=Depends(require_admin),
|
||||||
) -> ChannelTestResponse:
|
) -> ChannelTestResponse:
|
||||||
cfg = get_effective_telegram_config(db)
|
cfg = get_effective_telegram_config(db)
|
||||||
try:
|
try:
|
||||||
@@ -302,7 +302,7 @@ def test_telegram_settings(
|
|||||||
@router.post("/notifications/webhook/test", response_model=ChannelTestResponse)
|
@router.post("/notifications/webhook/test", response_model=ChannelTestResponse)
|
||||||
def test_webhook_settings(
|
def test_webhook_settings(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: str = Depends(get_current_user),
|
_user=Depends(require_admin),
|
||||||
) -> ChannelTestResponse:
|
) -> ChannelTestResponse:
|
||||||
cfg = get_effective_webhook_config(db)
|
cfg = get_effective_webhook_config(db)
|
||||||
try:
|
try:
|
||||||
@@ -318,7 +318,7 @@ def test_webhook_settings(
|
|||||||
@router.post("/notifications/email/test", response_model=ChannelTestResponse)
|
@router.post("/notifications/email/test", response_model=ChannelTestResponse)
|
||||||
def test_email_settings(
|
def test_email_settings(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_user: str = Depends(get_current_user),
|
_user=Depends(require_admin),
|
||||||
) -> ChannelTestResponse:
|
) -> ChannelTestResponse:
|
||||||
cfg = get_effective_email_config(db)
|
cfg = get_effective_email_config(db)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
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 CurrentUser, require_admin
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import USER_ROLE_ADMIN, USER_ROLE_MONITOR, USER_ROLES, User
|
||||||
|
from app.services.user_auth import create_user, hash_password
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
class UserSummary(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
role: str
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UserListResponse(BaseModel):
|
||||||
|
items: list[UserSummary]
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserRequest(BaseModel):
|
||||||
|
username: str = Field(min_length=2, max_length=64)
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
role: str = Field(default=USER_ROLE_MONITOR)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateUserRequest(BaseModel):
|
||||||
|
role: str | None = None
|
||||||
|
password: str | None = Field(default=None, min_length=8, max_length=128)
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=UserListResponse)
|
||||||
|
def list_users(
|
||||||
|
_admin: CurrentUser = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> UserListResponse:
|
||||||
|
rows = db.scalars(select(User).order_by(User.username)).all()
|
||||||
|
return UserListResponse(
|
||||||
|
items=[
|
||||||
|
UserSummary(
|
||||||
|
id=u.id,
|
||||||
|
username=u.username,
|
||||||
|
role=u.role,
|
||||||
|
is_active=u.is_active,
|
||||||
|
created_at=u.created_at,
|
||||||
|
)
|
||||||
|
for u in rows
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=UserSummary, status_code=201)
|
||||||
|
def create_user_endpoint(
|
||||||
|
body: CreateUserRequest,
|
||||||
|
admin: CurrentUser = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> UserSummary:
|
||||||
|
if body.role not in USER_ROLES:
|
||||||
|
raise HTTPException(status_code=422, detail=f"role must be one of: {', '.join(sorted(USER_ROLES))}")
|
||||||
|
try:
|
||||||
|
user = create_user(db, username=body.username, password=body.password, role=body.role)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return UserSummary(
|
||||||
|
id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
role=user.role,
|
||||||
|
is_active=user.is_active,
|
||||||
|
created_at=user.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}", response_model=UserSummary)
|
||||||
|
def update_user(
|
||||||
|
user_id: int,
|
||||||
|
body: UpdateUserRequest,
|
||||||
|
admin: CurrentUser = Depends(require_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> UserSummary:
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
if body.role is not None:
|
||||||
|
if body.role not in USER_ROLES:
|
||||||
|
raise HTTPException(status_code=422, detail=f"role must be one of: {', '.join(sorted(USER_ROLES))}")
|
||||||
|
if user.role == USER_ROLE_ADMIN and body.role != USER_ROLE_ADMIN:
|
||||||
|
_ensure_not_last_admin(db, user.id)
|
||||||
|
user.role = body.role
|
||||||
|
|
||||||
|
if body.password is not None:
|
||||||
|
user.password_hash = hash_password(body.password)
|
||||||
|
|
||||||
|
if body.is_active is not None:
|
||||||
|
if not body.is_active and user.username.lower() == admin.username.lower():
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot deactivate your own account")
|
||||||
|
if not body.is_active and user.role == USER_ROLE_ADMIN:
|
||||||
|
_ensure_not_last_admin(db, user.id)
|
||||||
|
user.is_active = body.is_active
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return UserSummary(
|
||||||
|
id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
role=user.role,
|
||||||
|
is_active=user.is_active,
|
||||||
|
created_at=user.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_not_last_admin(db: Session, user_id: int) -> None:
|
||||||
|
active_admins = db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(User)
|
||||||
|
.where(User.role == USER_ROLE_ADMIN, User.is_active.is_(True), User.id != user_id)
|
||||||
|
)
|
||||||
|
if not active_admins:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot remove or deactivate the last admin user")
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
@@ -5,18 +6,29 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status
|
||||||
|
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(subject: str) -> str:
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
from app.models.user import USER_ROLE_ADMIN
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
payload = {"sub": subject, "exp": expire}
|
@dataclass(frozen=True)
|
||||||
|
|
||||||
class CurrentUser:
|
class CurrentUser:
|
||||||
|
|
||||||
def _decode_username(token: str) -> str:
|
username: str
|
||||||
|
|
||||||
role: str
|
role: str
|
||||||
|
|
||||||
@@ -27,22 +39,32 @@ def _decode_username(token: str) -> str:
|
|||||||
def is_admin(self) -> bool:
|
def is_admin(self) -> bool:
|
||||||
|
|
||||||
return self.role == USER_ROLE_ADMIN
|
return self.role == USER_ROLE_ADMIN
|
||||||
return str(username)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(subject: str, role: str) -> str:
|
def create_access_token(subject: str, role: str) -> str:
|
||||||
|
|
||||||
"""Проверка JWT (в т.ч. query-параметр для SSE)."""
|
settings = get_settings()
|
||||||
return _decode_username(token)
|
|
||||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
|
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
|
||||||
|
|
||||||
payload = {"sub": subject, "role": role, "exp": expire}
|
payload = {"sub": subject, "role": role, "exp": expire}
|
||||||
|
|
||||||
) -> str:
|
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return _decode_username(credentials.credentials)
|
def _decode_current_user(token: str) -> CurrentUser:
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
payload = jwt.decode(
|
||||||
|
|
||||||
|
token,
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.auth.api_key import hash_api_key
|
|||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
from app.models import ApiKey
|
from app.models import ApiKey
|
||||||
|
from app.services.user_auth import bootstrap_admin_user
|
||||||
from app.version import APP_VERSION, APP_VERSION_LABEL
|
from app.version import APP_VERSION, APP_VERSION_LABEL
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
@@ -43,10 +44,19 @@ def bootstrap_api_key() -> None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_users() -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
bootstrap_admin_user(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_app: FastAPI):
|
async def lifespan(_app: FastAPI):
|
||||||
logger.info("%s — application startup (version %s)", APP_VERSION_LABEL, APP_VERSION)
|
logger.info("%s — application startup (version %s)", APP_VERSION_LABEL, APP_VERSION)
|
||||||
bootstrap_api_key()
|
bootstrap_api_key()
|
||||||
|
bootstrap_users()
|
||||||
yield
|
yield
|
||||||
logger.info("%s — application shutdown", APP_VERSION_LABEL)
|
logger.info("%s — application shutdown", APP_VERSION_LABEL)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from app.models.notification_channel import NotificationChannel
|
|||||||
from app.models.notification_cooldown import NotificationCooldown
|
from app.models.notification_cooldown import NotificationCooldown
|
||||||
from app.models.notification_policy import NotificationPolicy
|
from app.models.notification_policy import NotificationPolicy
|
||||||
from app.models.problem import Problem, ProblemEvent
|
from app.models.problem import Problem, ProblemEvent
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ApiKey",
|
"ApiKey",
|
||||||
@@ -15,4 +16,5 @@ __all__ = [
|
|||||||
"NotificationPolicy",
|
"NotificationPolicy",
|
||||||
"Problem",
|
"Problem",
|
||||||
"ProblemEvent",
|
"ProblemEvent",
|
||||||
|
"User",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
USER_ROLE_ADMIN = "admin"
|
||||||
|
USER_ROLE_MONITOR = "monitor"
|
||||||
|
USER_ROLES = frozenset({USER_ROLE_ADMIN, USER_ROLE_MONITOR})
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "sac_users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(128))
|
||||||
|
role: Mapped[str] = mapped_column(String(16), default=USER_ROLE_MONITOR)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""SAC UI users: password hashing, authentication, bootstrap."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models.user import USER_ROLE_ADMIN, USER_ROLE_MONITOR, USER_ROLES, User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(plain: str) -> str:
|
||||||
|
return _pwd_context.hash(plain)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, password_hash: str) -> bool:
|
||||||
|
try:
|
||||||
|
return _pwd_context.verify(plain, password_hash)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_username(db: Session, username: str) -> User | None:
|
||||||
|
normalized = username.strip()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
return db.scalar(
|
||||||
|
select(User).where(
|
||||||
|
func.lower(User.username) == normalized.lower(),
|
||||||
|
User.is_active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_user(db: Session, username: str, password: str) -> User | None:
|
||||||
|
user = get_user_by_username(db, username)
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
if not verify_password(password, user.password_hash):
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
role: str = USER_ROLE_MONITOR,
|
||||||
|
) -> User:
|
||||||
|
normalized = username.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("username is required")
|
||||||
|
if role not in USER_ROLES:
|
||||||
|
raise ValueError(f"invalid role: {role}")
|
||||||
|
if len(password) < 8:
|
||||||
|
raise ValueError("password must be at least 8 characters")
|
||||||
|
existing = db.scalar(select(User).where(func.lower(User.username) == normalized.lower()))
|
||||||
|
if existing is not None:
|
||||||
|
raise ValueError("username already exists")
|
||||||
|
user = User(
|
||||||
|
username=normalized,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
role=role,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_admin_user(db: Session) -> None:
|
||||||
|
"""Create first admin from SAC_ADMIN_* when sac_users table is empty."""
|
||||||
|
try:
|
||||||
|
count = db.scalar(select(func.count()).select_from(User)) or 0
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("SAC users bootstrap skipped (run alembic upgrade head): %s", exc)
|
||||||
|
db.rollback()
|
||||||
|
return
|
||||||
|
if count > 0:
|
||||||
|
return
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.sac_admin_password:
|
||||||
|
logger.warning("SAC users table empty and SAC_ADMIN_PASSWORD not set — UI login disabled until a user is created")
|
||||||
|
return
|
||||||
|
username = (settings.sac_admin_username or "admin").strip() or "admin"
|
||||||
|
create_user(db, username=username, password=settings.sac_admin_password, role=USER_ROLE_ADMIN)
|
||||||
|
db.commit()
|
||||||
|
logger.info("SAC bootstrap: created admin user %r from SAC_ADMIN_* env", username)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Manage SAC UI users (create admin/monitor accounts).
|
||||||
|
|
||||||
|
Examples (from backend/ on prod server):
|
||||||
|
|
||||||
|
python scripts/sac_manage_user.py create ivanov 'SecretPass123' --role monitor
|
||||||
|
python scripts/sac_manage_user.py list
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(BACKEND_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models.user import USER_ROLE_ADMIN, USER_ROLE_MONITOR, User
|
||||||
|
from app.services.user_auth import create_user
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list() -> int:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
rows = db.scalars(select(User).order_by(User.username)).all()
|
||||||
|
if not rows:
|
||||||
|
print("No users.")
|
||||||
|
return 0
|
||||||
|
for u in rows:
|
||||||
|
status = "active" if u.is_active else "inactive"
|
||||||
|
print(f"{u.id}\t{u.username}\t{u.role}\t{status}\t{u.created_at.isoformat()}")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_create(username: str, password: str, role: str) -> int:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
user = create_user(db, username=username, password=password, role=role)
|
||||||
|
db.commit()
|
||||||
|
print(f"Created user id={user.id} username={user.username!r} role={user.role}")
|
||||||
|
return 0
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"Error: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="SAC UI user management")
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
sub.add_parser("list", help="List UI users")
|
||||||
|
|
||||||
|
create_p = sub.add_parser("create", help="Create a user")
|
||||||
|
create_p.add_argument("username")
|
||||||
|
create_p.add_argument("password")
|
||||||
|
create_p.add_argument(
|
||||||
|
"--role",
|
||||||
|
choices=[USER_ROLE_ADMIN, USER_ROLE_MONITOR],
|
||||||
|
default=USER_ROLE_MONITOR,
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.command == "list":
|
||||||
|
return cmd_list()
|
||||||
|
if args.command == "create":
|
||||||
|
return cmd_create(args.username, args.password, args.role)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -18,7 +18,9 @@ from app.auth.jwt_auth import create_access_token
|
|||||||
from app.database import Base, get_db
|
from app.database import Base, get_db
|
||||||
from app.main import app as fastapi_app
|
from app.main import app as fastapi_app
|
||||||
import app.models # noqa: F401 — register all tables on Base.metadata
|
import app.models # noqa: F401 — register all tables on Base.metadata
|
||||||
from app.models import ApiKey
|
from app.models import ApiKey, User
|
||||||
|
from app.models.user import USER_ROLE_ADMIN, USER_ROLE_MONITOR
|
||||||
|
from app.services.user_auth import hash_password
|
||||||
|
|
||||||
TEST_API_KEY = os.environ["SAC_BOOTSTRAP_API_KEY"]
|
TEST_API_KEY = os.environ["SAC_BOOTSTRAP_API_KEY"]
|
||||||
|
|
||||||
@@ -57,6 +59,22 @@ def db_session(db_engine):
|
|||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
session.add(
|
||||||
|
User(
|
||||||
|
username="test-admin",
|
||||||
|
password_hash=hash_password("test-admin-password"),
|
||||||
|
role=USER_ROLE_ADMIN,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
User(
|
||||||
|
username="test-monitor",
|
||||||
|
password_hash=hash_password("test-monitor-password"),
|
||||||
|
role=USER_ROLE_MONITOR,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
yield session
|
yield session
|
||||||
session.close()
|
session.close()
|
||||||
@@ -65,6 +83,7 @@ def db_session(db_engine):
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(db_session, monkeypatch):
|
def client(db_session, monkeypatch):
|
||||||
monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None)
|
monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None)
|
||||||
|
monkeypatch.setattr("app.main.bootstrap_users", lambda: None)
|
||||||
|
|
||||||
def override_get_db():
|
def override_get_db():
|
||||||
try:
|
try:
|
||||||
@@ -85,5 +104,11 @@ def auth_headers():
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def jwt_headers():
|
def jwt_headers():
|
||||||
token = create_access_token("test-admin")
|
token = create_access_token("test-admin", USER_ROLE_ADMIN)
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def jwt_monitor_headers():
|
||||||
|
token = create_access_token("test-monitor", USER_ROLE_MONITOR)
|
||||||
return {"Authorization": f"Bearer {token}"}
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Multi-user UI auth and RBAC."""
|
||||||
|
|
||||||
|
from app.models.user import USER_ROLE_MONITOR
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_success(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"username": "test-admin", "password": "test-admin-password"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["username"] == "test-admin"
|
||||||
|
assert body["role"] == "admin"
|
||||||
|
assert body["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_monitor(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"username": "test-monitor", "password": "test-monitor-password"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["role"] == USER_ROLE_MONITOR
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_invalid_password(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"username": "test-admin", "password": "wrong"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_me(client, jwt_headers):
|
||||||
|
response = client.get("/api/v1/auth/me", headers=jwt_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"username": "test-admin", "role": "admin"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_forbidden_for_monitor(client, jwt_monitor_headers):
|
||||||
|
response = client.get("/api/v1/settings/notifications", headers=jwt_monitor_headers)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_events_allowed_for_monitor(client, jwt_monitor_headers):
|
||||||
|
response = client.get("/api/v1/events", headers=jwt_monitor_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_create_monitor_user(client, jwt_headers):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/users",
|
||||||
|
headers=jwt_headers,
|
||||||
|
json={"username": "new-monitor", "password": "longpassword1", "role": "monitor"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert response.json()["username"] == "new-monitor"
|
||||||
|
assert response.json()["role"] == "monitor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_monitor_cannot_list_users(client, jwt_monitor_headers):
|
||||||
|
response = client.get("/api/v1/users", headers=jwt_monitor_headers)
|
||||||
|
assert response.status_code == 403
|
||||||
@@ -308,6 +308,17 @@ sudo systemctl restart sac-api
|
|||||||
|
|
||||||
В `config/sac-api.env` задайте `SAC_ADMIN_PASSWORD`, затем откройте `https://sac.kalinamall.ru/` (логин `SAC_ADMIN_USERNAME`, по умолчанию `admin`).
|
В `config/sac-api.env` задайте `SAC_ADMIN_PASSWORD`, затем откройте `https://sac.kalinamall.ru/` (логин `SAC_ADMIN_USERNAME`, по умолчанию `admin`).
|
||||||
|
|
||||||
|
После обновления с multi-user (миграция `009_sac_users`): выполните `alembic upgrade head` в каталоге `backend/`. При первом старте API создаст пользователя **admin** из `SAC_ADMIN_*`, если таблица `sac_users` пуста.
|
||||||
|
|
||||||
|
Добавить сотрудника с ролью **monitor** (просмотр без «Настройки»):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/security-alert-center/backend
|
||||||
|
sudo -u sac python scripts/sac_manage_user.py create ivanov 'StrongPass123' --role monitor
|
||||||
|
```
|
||||||
|
|
||||||
|
Роли: **admin** — полный доступ; **monitor** — события, хосты, проблемы, отчёты (без `/settings` и `/users`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Проверка
|
## 11. Проверка
|
||||||
|
|||||||
@@ -8,11 +8,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed, onMounted } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { getToken } from "./api";
|
import { getToken } from "./api";
|
||||||
import AppSidebar from "./components/AppSidebar.vue";
|
import AppSidebar from "./components/AppSidebar.vue";
|
||||||
|
import { refreshSessionRole } from "./router";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const showShell = computed(() => route.path !== "/login" && !!getToken());
|
const showShell = computed(() => route.path !== "/login" && !!getToken());
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (getToken()) {
|
||||||
|
void refreshSessionRole();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,15 +1,34 @@
|
|||||||
const TOKEN_KEY = "sac_token";
|
const TOKEN_KEY = "sac_token";
|
||||||
|
const ROLE_KEY = "sac_role";
|
||||||
|
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
return localStorage.getItem(TOKEN_KEY);
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRole(): string | null {
|
||||||
|
return localStorage.getItem(ROLE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdmin(): boolean {
|
||||||
|
return getRole() === "admin";
|
||||||
|
}
|
||||||
|
|
||||||
export function setToken(token: string): void {
|
export function setToken(token: string): void {
|
||||||
localStorage.setItem(TOKEN_KEY, token);
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setRole(role: string): void {
|
||||||
|
localStorage.setItem(ROLE_KEY, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAuth(token: string, role: string): void {
|
||||||
|
setToken(token);
|
||||||
|
setRole(role);
|
||||||
|
}
|
||||||
|
|
||||||
export function clearToken(): void {
|
export function clearToken(): void {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(ROLE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
@@ -27,6 +46,9 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
|||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
|
if (res.status === 403) {
|
||||||
|
throw new Error("Forbidden");
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
throw new Error(text || res.statusText);
|
throw new Error(text || res.statusText);
|
||||||
@@ -37,6 +59,17 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
|||||||
export interface TokenResponse {
|
export interface TokenResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeResponse {
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchMe(): Promise<MeResponse> {
|
||||||
|
return apiFetch<MeResponse>("/api/v1/auth/me");
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventSummary {
|
export interface EventSummary {
|
||||||
|
|||||||
@@ -29,8 +29,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { clearToken } from "../api";
|
import { clearToken, isAdmin } from "../api";
|
||||||
import { APP_NAME, APP_VERSION } from "../version";
|
import { APP_NAME, APP_VERSION } from "../version";
|
||||||
|
|
||||||
const appName = APP_NAME;
|
const appName = APP_NAME;
|
||||||
@@ -38,16 +39,24 @@ const appVersion = APP_VERSION;
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const navItems = [
|
const navItems = computed(() => {
|
||||||
|
const items = [
|
||||||
{ to: "/dashboard", label: "Обзор", icon: "▦", match: "exact" as const },
|
{ to: "/dashboard", label: "Обзор", icon: "▦", match: "exact" as const },
|
||||||
{ to: "/events", label: "События", icon: "☰", match: "prefix" as const },
|
{ to: "/events", label: "События", icon: "☰", match: "prefix" as const },
|
||||||
{ to: "/reports", label: "Отчёты", icon: "▤", match: "prefix" as const },
|
{ to: "/reports", label: "Отчёты", icon: "▤", match: "prefix" as const },
|
||||||
{ to: "/problems", label: "Проблемы", icon: "!", match: "prefix" as const },
|
{ to: "/problems", label: "Проблемы", icon: "!", match: "prefix" as const },
|
||||||
{ to: "/hosts", label: "Хосты", icon: "⬢", match: "exact" as const },
|
{ to: "/hosts", label: "Хосты", icon: "⬢", match: "exact" as const },
|
||||||
{ to: "/settings", label: "Настройки", icon: "⚙", match: "exact" as const },
|
|
||||||
];
|
];
|
||||||
|
if (isAdmin()) {
|
||||||
|
items.push(
|
||||||
|
{ to: "/settings", label: "Настройки", icon: "⚙", match: "exact" as const },
|
||||||
|
{ to: "/users", label: "Пользователи", icon: "👤", match: "exact" as const },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
function isActive(item: (typeof navItems)[number]) {
|
function isActive(item: (typeof navItems.value)[number]) {
|
||||||
if (item.match === "exact") {
|
if (item.match === "exact") {
|
||||||
return route.path === item.to;
|
return route.path === item.to;
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-3
@@ -1,5 +1,5 @@
|
|||||||
import { createRouter, createWebHistory } from "vue-router";
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
import { getToken } from "./api";
|
import { getToken, isAdmin, setRole, fetchMe } from "./api";
|
||||||
import EventsView from "./views/EventsView.vue";
|
import EventsView from "./views/EventsView.vue";
|
||||||
import EventDetailView from "./views/EventDetailView.vue";
|
import EventDetailView from "./views/EventDetailView.vue";
|
||||||
import HostsView from "./views/HostsView.vue";
|
import HostsView from "./views/HostsView.vue";
|
||||||
@@ -9,6 +9,7 @@ import ProblemDetailView from "./views/ProblemDetailView.vue";
|
|||||||
import DashboardView from "./views/DashboardView.vue";
|
import DashboardView from "./views/DashboardView.vue";
|
||||||
import ReportsView from "./views/ReportsView.vue";
|
import ReportsView from "./views/ReportsView.vue";
|
||||||
import SettingsView from "./views/SettingsView.vue";
|
import SettingsView from "./views/SettingsView.vue";
|
||||||
|
import UsersView from "./views/UsersView.vue";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -22,14 +23,28 @@ const router = createRouter({
|
|||||||
{ path: "/hosts", component: HostsView },
|
{ path: "/hosts", component: HostsView },
|
||||||
{ path: "/problems", component: ProblemsView },
|
{ path: "/problems", component: ProblemsView },
|
||||||
{ path: "/problems/:id", component: ProblemDetailView, props: true },
|
{ path: "/problems/:id", component: ProblemDetailView, props: true },
|
||||||
{ path: "/settings", component: SettingsView },
|
{ path: "/settings", component: SettingsView, meta: { adminOnly: true } },
|
||||||
|
{ path: "/users", component: UsersView, meta: { adminOnly: true } },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
router.beforeEach(async (to) => {
|
||||||
if (to.path === "/login") return true;
|
if (to.path === "/login") return true;
|
||||||
if (!getToken()) return { path: "/login" };
|
if (!getToken()) return { path: "/login" };
|
||||||
|
if (to.meta.adminOnly && !isAdmin()) {
|
||||||
|
return { path: "/dashboard" };
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
export async function refreshSessionRole(): Promise<void> {
|
||||||
|
if (!getToken()) return;
|
||||||
|
try {
|
||||||
|
const me = await fetchMe();
|
||||||
|
setRole(me.role);
|
||||||
|
} catch {
|
||||||
|
/* 401 handled in apiFetch */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { apiFetch, setToken, type TokenResponse } from "../api";
|
import { apiFetch, setAuth, type TokenResponse } from "../api";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const username = ref("admin");
|
const username = ref("admin");
|
||||||
@@ -41,7 +41,7 @@ async function submit() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ username: username.value, password: password.value }),
|
body: JSON.stringify({ username: username.value, password: password.value }),
|
||||||
});
|
});
|
||||||
setToken(data.access_token);
|
setAuth(data.access_token, data.role);
|
||||||
await router.push("/events");
|
await router.push("/events");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : "Ошибка входа";
|
error.value = e instanceof Error ? e.message : "Ошибка входа";
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<template>
|
||||||
|
<div class="users-page">
|
||||||
|
<h1>Пользователи</h1>
|
||||||
|
<p class="users-intro">
|
||||||
|
Учётные записи входа в веб-панель SAC. Роль <strong>monitor</strong> — просмотр без раздела «Настройки».
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
<p v-if="success" class="success">{{ success }}</p>
|
||||||
|
|
||||||
|
<section class="card users-card">
|
||||||
|
<h2>Новый пользователь</h2>
|
||||||
|
<form class="users-form" @submit.prevent="createUser">
|
||||||
|
<label>
|
||||||
|
Логин
|
||||||
|
<input v-model="form.username" required minlength="2" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Пароль
|
||||||
|
<input v-model="form.password" type="password" required minlength="8" autocomplete="new-password" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Роль
|
||||||
|
<select v-model="form.role">
|
||||||
|
<option value="monitor">monitor — просмотр</option>
|
||||||
|
<option value="admin">admin — полный доступ</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit" :disabled="creating">{{ creating ? "Создание…" : "Создать" }}</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card users-card">
|
||||||
|
<h2>Список</h2>
|
||||||
|
<p v-if="loading">Загрузка…</p>
|
||||||
|
<table v-else class="users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Логин</th>
|
||||||
|
<th>Роль</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Создан</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="u in users" :key="u.id">
|
||||||
|
<td>{{ u.username }}</td>
|
||||||
|
<td><code>{{ u.role }}</code></td>
|
||||||
|
<td>{{ u.is_active ? "активен" : "отключён" }}</td>
|
||||||
|
<td>{{ formatDate(u.created_at) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from "vue";
|
||||||
|
import { apiFetch } from "../api";
|
||||||
|
|
||||||
|
interface UserSummary {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserListResponse {
|
||||||
|
items: UserSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = ref<UserSummary[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const creating = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
const success = ref("");
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
role: "monitor",
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<UserListResponse>("/api/v1/users");
|
||||||
|
users.value = data.items;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser() {
|
||||||
|
creating.value = true;
|
||||||
|
error.value = "";
|
||||||
|
success.value = "";
|
||||||
|
try {
|
||||||
|
await apiFetch<UserSummary>("/api/v1/users", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: form.username.trim(),
|
||||||
|
password: form.password,
|
||||||
|
role: form.role,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
success.value = `Пользователь ${form.username} создан`;
|
||||||
|
form.username = "";
|
||||||
|
form.password = "";
|
||||||
|
form.role = "monitor";
|
||||||
|
await loadUsers();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Ошибка создания";
|
||||||
|
} finally {
|
||||||
|
creating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadUsers);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.users-page {
|
||||||
|
max-width: 52rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-intro {
|
||||||
|
color: #9aa4b2;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-card {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-width: 24rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table th,
|
||||||
|
.users-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.45rem 0.5rem;
|
||||||
|
border-bottom: 1px solid #2a3441;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ff8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: #7dcea0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user