From 86ec04f2470c1ec0c8206ea62c70f3881ecb3de0 Mon Sep 17 00:00:00 2001 From: qdaxb <4157870+qdaxb@users.noreply.github.com> Date: Tue, 9 Dec 2025 13:54:50 +0800 Subject: [PATCH] feat(admin): add user impersonation feature Add a new admin feature allowing administrators to impersonate users for troubleshooting purposes. The feature requires explicit user authorization before the impersonation session can begin. Backend changes: - Add ImpersonationRequest and ImpersonationAuditLog models - Add Pydantic schemas for impersonation API - Add impersonation service with request lifecycle management - Extend security.py with impersonation JWT token support - Add admin API routes for managing impersonation requests - Add public confirmation API routes for user authorization - Add Alembic migration for new tables Frontend changes: - Add TypeScript types for impersonation - Add impersonation API client - Add ImpersonationBanner component for active session indicator - Extend UserContext with impersonation state management - Add ImpersonateDialog component for creating requests - Add impersonate button to UserList component - Add user confirmation page at /impersonate/confirm/[token] - Add i18n translations (en/zh-CN) Security features: - User must explicitly approve impersonation requests - Authorization links expire after 30 minutes - Sessions expire after 24 hours - All impersonation actions are logged for audit --- .../versions/add_impersonation_tables.py | 161 +++++ backend/app/api/api.py | 17 +- .../app/api/endpoints/impersonate_confirm.py | 115 ++++ backend/app/api/endpoints/impersonation.py | 272 +++++++++ backend/app/core/security.py | 166 ++++- backend/app/models/__init__.py | 11 +- backend/app/models/impersonation.py | 83 +++ backend/app/schemas/impersonation.py | 127 ++++ backend/app/services/impersonation_service.py | 566 ++++++++++++++++++ frontend/src/apis/impersonation.ts | 115 ++++ .../app/impersonate/confirm/[token]/page.tsx | 337 +++++++++++ .../components/common/ImpersonationBanner.tsx | 110 ++++ .../admin/components/ImpersonateDialog.tsx | 300 ++++++++++ .../features/admin/components/UserList.tsx | 26 + frontend/src/features/common/UserContext.tsx | 92 ++- frontend/src/i18n/locales/en/admin.json | 39 ++ frontend/src/i18n/locales/zh-CN/admin.json | 39 ++ frontend/src/types/impersonation.ts | 90 +++ 18 files changed, 2661 insertions(+), 5 deletions(-) create mode 100644 backend/alembic/versions/add_impersonation_tables.py create mode 100644 backend/app/api/endpoints/impersonate_confirm.py create mode 100644 backend/app/api/endpoints/impersonation.py create mode 100644 backend/app/models/impersonation.py create mode 100644 backend/app/schemas/impersonation.py create mode 100644 backend/app/services/impersonation_service.py create mode 100644 frontend/src/apis/impersonation.ts create mode 100644 frontend/src/app/impersonate/confirm/[token]/page.tsx create mode 100644 frontend/src/components/common/ImpersonationBanner.tsx create mode 100644 frontend/src/features/admin/components/ImpersonateDialog.tsx create mode 100644 frontend/src/types/impersonation.ts diff --git a/backend/alembic/versions/add_impersonation_tables.py b/backend/alembic/versions/add_impersonation_tables.py new file mode 100644 index 00000000..c1356c02 --- /dev/null +++ b/backend/alembic/versions/add_impersonation_tables.py @@ -0,0 +1,161 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Add impersonation tables + +Revision ID: add_impersonation_tables +Revises: add_user_preferences +Create Date: 2025-12-09 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "add_impersonation_tables" +down_revision: Union[str, None] = "add_user_preferences" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create impersonation_requests table + op.create_table( + "impersonation_requests", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("admin_user_id", sa.Integer(), nullable=False), + sa.Column("target_user_id", sa.Integer(), nullable=False), + sa.Column("token", sa.String(64), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("approved_at", sa.DateTime(), nullable=True), + sa.Column("session_expires_at", sa.DateTime(), nullable=True), + sa.Column( + "created_at", sa.DateTime(), nullable=True, server_default=sa.func.now() + ), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=True, + server_default=sa.func.now(), + onupdate=sa.func.now(), + ), + sa.ForeignKeyConstraint( + ["admin_user_id"], + ["users.id"], + name="fk_impersonation_requests_admin_user", + ), + sa.ForeignKeyConstraint( + ["target_user_id"], + ["users.id"], + name="fk_impersonation_requests_target_user", + ), + sa.PrimaryKeyConstraint("id"), + mysql_engine="InnoDB", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_unicode_ci", + ) + + # Create indexes for impersonation_requests + op.create_index( + "ix_impersonation_requests_id", "impersonation_requests", ["id"], unique=False + ) + op.create_index( + "ix_impersonation_requests_admin_user_id", + "impersonation_requests", + ["admin_user_id"], + unique=False, + ) + op.create_index( + "ix_impersonation_requests_target_user_id", + "impersonation_requests", + ["target_user_id"], + unique=False, + ) + op.create_index( + "ix_impersonation_requests_token", + "impersonation_requests", + ["token"], + unique=True, + ) + + # Create impersonation_audit_logs table + op.create_table( + "impersonation_audit_logs", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("impersonation_request_id", sa.Integer(), nullable=False), + sa.Column("admin_user_id", sa.Integer(), nullable=False), + sa.Column("target_user_id", sa.Integer(), nullable=False), + sa.Column("action", sa.String(50), nullable=False), + sa.Column("method", sa.String(10), nullable=False), + sa.Column("path", sa.String(500), nullable=False), + sa.Column("request_body", sa.Text(), nullable=True), + sa.Column("ip_address", sa.String(50), nullable=True), + sa.Column("user_agent", sa.String(500), nullable=True), + sa.Column( + "created_at", sa.DateTime(), nullable=True, server_default=sa.func.now() + ), + sa.ForeignKeyConstraint( + ["impersonation_request_id"], + ["impersonation_requests.id"], + name="fk_impersonation_audit_logs_request", + ), + sa.ForeignKeyConstraint( + ["admin_user_id"], + ["users.id"], + name="fk_impersonation_audit_logs_admin_user", + ), + sa.ForeignKeyConstraint( + ["target_user_id"], + ["users.id"], + name="fk_impersonation_audit_logs_target_user", + ), + sa.PrimaryKeyConstraint("id"), + mysql_engine="InnoDB", + mysql_charset="utf8mb4", + mysql_collate="utf8mb4_unicode_ci", + ) + + # Create indexes for impersonation_audit_logs + op.create_index( + "ix_impersonation_audit_logs_id", "impersonation_audit_logs", ["id"], unique=False + ) + op.create_index( + "ix_impersonation_audit_logs_request_id", + "impersonation_audit_logs", + ["impersonation_request_id"], + unique=False, + ) + op.create_index( + "ix_impersonation_audit_logs_admin_user_id", + "impersonation_audit_logs", + ["admin_user_id"], + unique=False, + ) + op.create_index( + "ix_impersonation_audit_logs_target_user_id", + "impersonation_audit_logs", + ["target_user_id"], + unique=False, + ) + + +def downgrade() -> None: + # Drop impersonation_audit_logs table and indexes + op.drop_index("ix_impersonation_audit_logs_target_user_id", "impersonation_audit_logs") + op.drop_index("ix_impersonation_audit_logs_admin_user_id", "impersonation_audit_logs") + op.drop_index("ix_impersonation_audit_logs_request_id", "impersonation_audit_logs") + op.drop_index("ix_impersonation_audit_logs_id", "impersonation_audit_logs") + op.drop_table("impersonation_audit_logs") + + # Drop impersonation_requests table and indexes + op.drop_index("ix_impersonation_requests_token", "impersonation_requests") + op.drop_index("ix_impersonation_requests_target_user_id", "impersonation_requests") + op.drop_index("ix_impersonation_requests_admin_user_id", "impersonation_requests") + op.drop_index("ix_impersonation_requests_id", "impersonation_requests") + op.drop_table("impersonation_requests") diff --git a/backend/app/api/api.py b/backend/app/api/api.py index 079cd503..5f5c83c4 100644 --- a/backend/app/api/api.py +++ b/backend/app/api/api.py @@ -2,7 +2,16 @@ # # SPDX-License-Identifier: Apache-2.0 -from app.api.endpoints import admin, auth, oidc, quota, repository, users +from app.api.endpoints import ( + admin, + auth, + impersonate_confirm, + impersonation, + oidc, + quota, + repository, + users, +) from app.api.endpoints.adapter import ( agents, attachments, @@ -22,6 +31,12 @@ api_router.include_router(oidc.router, prefix="/auth/oidc", tags=["auth", "oidc"]) api_router.include_router(users.router, prefix="/users", tags=["users"]) api_router.include_router(admin.router, prefix="/admin", tags=["admin"]) +api_router.include_router( + impersonation.router, prefix="/admin", tags=["admin", "impersonation"] +) +api_router.include_router( + impersonate_confirm.router, prefix="/impersonate", tags=["impersonation"] +) api_router.include_router(bots.router, prefix="/bots", tags=["bots"]) api_router.include_router(models.router, prefix="/models", tags=["public-models"]) api_router.include_router(shells.router, prefix="/shells", tags=["shells"]) diff --git a/backend/app/api/endpoints/impersonate_confirm.py b/backend/app/api/endpoints/impersonate_confirm.py new file mode 100644 index 00000000..a98c2f91 --- /dev/null +++ b/backend/app/api/endpoints/impersonate_confirm.py @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +""" +Public API routes for impersonation confirmation (user-facing). +""" + +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Path, status +from sqlalchemy.orm import Session + +from app.api.dependencies import get_db +from app.core.security import get_current_user +from app.models.user import User +from app.schemas.impersonation import ImpersonationConfirmInfo +from app.services.impersonation_service import impersonation_service + +router = APIRouter() + + +@router.get("/confirm/{token}", response_model=ImpersonationConfirmInfo) +async def get_impersonation_confirm_info( + token: str = Path(..., description="Impersonation request token"), + db: Session = Depends(get_db), +): + """ + Get impersonation request information for the confirmation page. + + This endpoint is publicly accessible so users can view the request + before logging in to approve/reject it. + """ + request = impersonation_service.get_request_by_token(db, token) + + # Calculate remaining time + now = datetime.now(timezone.utc) + if request.expires_at.tzinfo is None: + expires_at = request.expires_at.replace(tzinfo=timezone.utc) + else: + expires_at = request.expires_at + + remaining_seconds = max(0, int((expires_at - now).total_seconds())) + + # Check if expired + if remaining_seconds == 0 and request.status == "pending": + request.status = "expired" + db.commit() + + return ImpersonationConfirmInfo( + id=request.id, + admin_user_name=request.admin_user.user_name, + target_user_name=request.target_user.user_name, + status=request.status, + expires_at=request.expires_at, + remaining_seconds=remaining_seconds, + created_at=request.created_at, + ) + + +@router.post("/confirm/{token}/approve", response_model=ImpersonationConfirmInfo) +async def approve_impersonation_request( + token: str = Path(..., description="Impersonation request token"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Approve an impersonation request. + + The target user must be logged in to approve the request. + """ + request = impersonation_service.approve_request(db, token, current_user) + + # Calculate remaining time for session + now = datetime.now(timezone.utc) + if request.session_expires_at.tzinfo is None: + session_expires_at = request.session_expires_at.replace(tzinfo=timezone.utc) + else: + session_expires_at = request.session_expires_at + + remaining_seconds = max(0, int((session_expires_at - now).total_seconds())) + + return ImpersonationConfirmInfo( + id=request.id, + admin_user_name=request.admin_user.user_name, + target_user_name=request.target_user.user_name, + status=request.status, + expires_at=request.session_expires_at, # Use session expiry after approval + remaining_seconds=remaining_seconds, + created_at=request.created_at, + ) + + +@router.post("/confirm/{token}/reject", response_model=ImpersonationConfirmInfo) +async def reject_impersonation_request( + token: str = Path(..., description="Impersonation request token"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Reject an impersonation request. + + The target user must be logged in to reject the request. + """ + request = impersonation_service.reject_request(db, token, current_user) + + return ImpersonationConfirmInfo( + id=request.id, + admin_user_name=request.admin_user.user_name, + target_user_name=request.target_user.user_name, + status=request.status, + expires_at=request.expires_at, + remaining_seconds=0, + created_at=request.created_at, + ) diff --git a/backend/app/api/endpoints/impersonation.py b/backend/app/api/endpoints/impersonation.py new file mode 100644 index 00000000..3af12b82 --- /dev/null +++ b/backend/app/api/endpoints/impersonation.py @@ -0,0 +1,272 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +""" +Admin API routes for impersonation feature. +""" + +from datetime import datetime, timezone +from typing import Optional + +from fastapi import APIRouter, Depends, Path, Query, status +from sqlalchemy.orm import Session + +from app.api.dependencies import get_db +from app.core.security import ( + create_access_token, + create_impersonation_token, + get_admin_user, + get_current_user_with_impersonation_info, +) +from app.models.user import User +from app.schemas.impersonation import ( + ImpersonationAuditLogListResponse, + ImpersonationAuditLogResponse, + ImpersonationExitResponse, + ImpersonationRequestCreate, + ImpersonationRequestListResponse, + ImpersonationRequestResponse, + ImpersonationStartResponse, +) +from app.services.impersonation_service import impersonation_service + +router = APIRouter() + + +def _format_request_response( + request, confirmation_url: str +) -> ImpersonationRequestResponse: + """Helper to format impersonation request response.""" + return ImpersonationRequestResponse( + id=request.id, + admin_user_id=request.admin_user_id, + admin_user_name=request.admin_user.user_name, + target_user_id=request.target_user_id, + target_user_name=request.target_user.user_name, + token=request.token, + status=request.status, + confirmation_url=confirmation_url, + expires_at=request.expires_at, + approved_at=request.approved_at, + session_expires_at=request.session_expires_at, + created_at=request.created_at, + updated_at=request.updated_at, + ) + + +@router.post( + "/impersonate/request", + response_model=ImpersonationRequestResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_impersonation_request( + data: ImpersonationRequestCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +): + """ + Create a new impersonation request. + + Admin creates a request to impersonate a target user. The request generates + a confirmation link that must be approved by the target user. + """ + request = impersonation_service.create_request( + db=db, + admin_user=current_user, + target_user_id=data.target_user_id, + ) + + confirmation_url = impersonation_service.get_confirmation_url(request.token) + return _format_request_response(request, confirmation_url) + + +@router.get("/impersonate/requests", response_model=ImpersonationRequestListResponse) +async def list_impersonation_requests( + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), + status_filter: Optional[str] = Query( + None, description="Filter by status: pending, approved, rejected, expired, used" + ), + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +): + """ + List impersonation requests created by the current admin. + """ + requests, total = impersonation_service.list_requests( + db=db, + admin_user_id=current_user.id, + page=page, + limit=limit, + status_filter=status_filter, + ) + + items = [ + _format_request_response( + req, impersonation_service.get_confirmation_url(req.token) + ) + for req in requests + ] + + return ImpersonationRequestListResponse(total=total, items=items) + + +@router.get( + "/impersonate/requests/{request_id}", response_model=ImpersonationRequestResponse +) +async def get_impersonation_request( + request_id: int = Path(..., description="Impersonation request ID"), + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +): + """ + Get details of a specific impersonation request. + """ + request = impersonation_service.get_request( + db=db, request_id=request_id, admin_user_id=current_user.id + ) + + confirmation_url = impersonation_service.get_confirmation_url(request.token) + return _format_request_response(request, confirmation_url) + + +@router.post( + "/impersonate/requests/{request_id}/cancel", + response_model=ImpersonationRequestResponse, +) +async def cancel_impersonation_request( + request_id: int = Path(..., description="Impersonation request ID"), + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +): + """ + Cancel a pending impersonation request. + """ + request = impersonation_service.cancel_request( + db=db, request_id=request_id, admin_user_id=current_user.id + ) + + confirmation_url = impersonation_service.get_confirmation_url(request.token) + return _format_request_response(request, confirmation_url) + + +@router.post( + "/impersonate/start/{request_id}", response_model=ImpersonationStartResponse +) +async def start_impersonation_session( + request_id: int = Path(..., description="Impersonation request ID"), + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +): + """ + Start an impersonation session after the target user has approved the request. + + Returns a special JWT token that allows the admin to act as the target user. + """ + request, target_user = impersonation_service.start_session( + db=db, request_id=request_id, admin_user=current_user + ) + + # Create impersonation token + access_token = create_impersonation_token( + target_user=target_user, + admin_user=current_user, + impersonation_request_id=request.id, + session_expires_at=request.session_expires_at, + ) + + return ImpersonationStartResponse( + access_token=access_token, + token_type="bearer", + impersonated_user_id=target_user.id, + impersonated_user_name=target_user.user_name, + session_expires_at=request.session_expires_at, + ) + + +@router.post("/impersonate/exit", response_model=ImpersonationExitResponse) +async def exit_impersonation_session( + db: Session = Depends(get_db), + user_info: tuple = Depends(get_current_user_with_impersonation_info), +): + """ + Exit the current impersonation session and restore admin identity. + + Returns a new JWT token for the original admin user. + """ + user, impersonation_info = user_info + + if not impersonation_info.get("is_impersonating"): + # Not in impersonation mode, return current user's token + access_token = create_access_token(data={"sub": user.user_name}) + return ImpersonationExitResponse( + access_token=access_token, + message="No active impersonation session", + ) + + # Get the admin user + admin_user_id = impersonation_info.get("impersonator_id") + admin_user = db.query(User).filter(User.id == admin_user_id).first() + + if not admin_user: + # Fallback to current user if admin not found + access_token = create_access_token(data={"sub": user.user_name}) + return ImpersonationExitResponse( + access_token=access_token, + message="Admin user not found, restored current user session", + ) + + # Create new token for admin user + access_token = create_access_token(data={"sub": admin_user.user_name}) + + return ImpersonationExitResponse( + access_token=access_token, + message=f"Successfully exited impersonation of {user.user_name}", + ) + + +@router.get("/impersonate/audit-logs", response_model=ImpersonationAuditLogListResponse) +async def list_audit_logs( + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=100), + request_id: Optional[int] = Query(None, description="Filter by request ID"), + target_user_id: Optional[int] = Query(None, description="Filter by target user ID"), + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +): + """ + List impersonation audit logs. + + Admins can view audit logs for their own impersonation sessions. + """ + logs, total = impersonation_service.list_audit_logs( + db=db, + page=page, + limit=limit, + request_id=request_id, + admin_user_id=current_user.id, + target_user_id=target_user_id, + ) + + items = [] + for log in logs: + items.append( + ImpersonationAuditLogResponse( + id=log.id, + impersonation_request_id=log.impersonation_request_id, + admin_user_id=log.admin_user_id, + admin_user_name=log.impersonation_request.admin_user.user_name, + target_user_id=log.target_user_id, + target_user_name=log.impersonation_request.target_user.user_name, + action=log.action, + method=log.method, + path=log.path, + request_body=log.request_body, + ip_address=log.ip_address, + user_agent=log.user_agent, + created_at=log.created_at, + ) + ) + + return ImpersonationAuditLogListResponse(total=total, items=items) diff --git a/backend/app/core/security.py b/backend/app/core/security.py index f94bf239..984ef50f 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -3,9 +3,9 @@ # SPDX-License-Identifier: Apache-2.0 from datetime import datetime, timedelta -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, Optional, Tuple, Union -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt from passlib.context import CryptContext @@ -211,3 +211,165 @@ def get_admin_user(current_user: User = Depends(get_current_user)) -> User: detail="Permission denied. Admin access required.", ) return current_user + + +def create_impersonation_token( + target_user: User, + admin_user: User, + impersonation_request_id: int, + session_expires_at: datetime, +) -> str: + """ + Create an impersonation access token. + + Args: + target_user: User being impersonated + admin_user: Admin performing the impersonation + impersonation_request_id: ID of the impersonation request + session_expires_at: When the session expires + + Returns: + JWT access token for impersonation + """ + from datetime import timezone + + to_encode = { + "sub": target_user.user_name, + "user_id": target_user.id, + "impersonator_id": admin_user.id, + "impersonator_name": admin_user.user_name, + "impersonation_request_id": impersonation_request_id, + "is_impersonating": True, + "exp": session_expires_at, + } + + encoded_jwt = jwt.encode( + to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM + ) + return encoded_jwt + + +def verify_impersonation_token(token: str) -> Dict[str, Any]: + """ + Verify an impersonation token and extract its data. + + Args: + token: JWT token + + Returns: + Token payload including impersonation details + + Raises: + HTTPException: If token is invalid + """ + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate impersonation credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + username: str = payload.get("sub") + is_impersonating: bool = payload.get("is_impersonating", False) + + if username is None: + raise credentials_exception + + return { + "username": username, + "user_id": payload.get("user_id"), + "is_impersonating": is_impersonating, + "impersonator_id": payload.get("impersonator_id"), + "impersonator_name": payload.get("impersonator_name"), + "impersonation_request_id": payload.get("impersonation_request_id"), + } + except JWTError: + raise credentials_exception + + +def get_current_user_with_impersonation_info( + token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) +) -> Tuple[User, Dict[str, Any]]: + """ + Get current user with impersonation information. + + Args: + token: JWT token + db: Database session + + Returns: + Tuple of (user, impersonation_info) + """ + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + username: str = payload.get("sub") + + if username is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user = user_service.get_user_by_name(db=db, user_name=username) + if user is None or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or inactive", + headers={"WWW-Authenticate": "Bearer"}, + ) + + impersonation_info = { + "is_impersonating": payload.get("is_impersonating", False), + "impersonator_id": payload.get("impersonator_id"), + "impersonator_name": payload.get("impersonator_name"), + "impersonation_request_id": payload.get("impersonation_request_id"), + } + + return user, impersonation_info + + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def get_impersonation_info_from_request(request: Request) -> Optional[Dict[str, Any]]: + """ + Extract impersonation info from request authorization header. + + Args: + request: FastAPI Request object + + Returns: + Impersonation info dict or None if not impersonating + """ + auth_header = request.headers.get("Authorization") + if not auth_header or not auth_header.startswith("Bearer "): + return None + + try: + token = auth_header.split(" ")[1] + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + + if not payload.get("is_impersonating"): + return None + + return { + "is_impersonating": True, + "impersonator_id": payload.get("impersonator_id"), + "impersonator_name": payload.get("impersonator_name"), + "impersonation_request_id": payload.get("impersonation_request_id"), + "target_user_id": payload.get("user_id"), + } + except (JWTError, IndexError): + return None diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 65bf81ad..9ffa548d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,6 +5,7 @@ """ Models package """ +from app.models.impersonation import ImpersonationAuditLog, ImpersonationRequest from app.models.kind import Kind from app.models.shared_team import SharedTeam from app.models.skill_binary import SkillBinary @@ -14,4 +15,12 @@ # All models should import Base directly from app.db.base from app.models.user import User -__all__ = ["User", "Kind", "Subtask", "SharedTeam", "SkillBinary"] +__all__ = [ + "User", + "Kind", + "Subtask", + "SharedTeam", + "SkillBinary", + "ImpersonationRequest", + "ImpersonationAuditLog", +] diff --git a/backend/app/models/impersonation.py b/backend/app/models/impersonation.py new file mode 100644 index 00000000..5749b9c9 --- /dev/null +++ b/backend/app/models/impersonation.py @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +""" +Impersonation models for admin user impersonation feature. +""" + +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.db.base import Base + + +class ImpersonationRequest(Base): + """Model for storing impersonation requests from admin users.""" + + __tablename__ = "impersonation_requests" + + id = Column(Integer, primary_key=True, index=True) + admin_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + target_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + token = Column(String(64), nullable=False, unique=True, index=True) + status = Column( + String(20), nullable=False, default="pending" + ) # pending, approved, rejected, expired, used + expires_at = Column(DateTime, nullable=False) + approved_at = Column(DateTime, nullable=True) + session_expires_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + # Relationships + admin_user = relationship( + "User", foreign_keys=[admin_user_id], backref="impersonation_requests_as_admin" + ) + target_user = relationship( + "User", foreign_keys=[target_user_id], backref="impersonation_requests_as_target" + ) + + __table_args__ = ( + { + "sqlite_autoincrement": True, + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, + ) + + +class ImpersonationAuditLog(Base): + """Model for storing audit logs during impersonation sessions.""" + + __tablename__ = "impersonation_audit_logs" + + id = Column(Integer, primary_key=True, index=True) + impersonation_request_id = Column( + Integer, ForeignKey("impersonation_requests.id"), nullable=False, index=True + ) + admin_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + target_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + action = Column(String(50), nullable=False) # e.g., api_call, page_view + method = Column(String(10), nullable=False) # HTTP method + path = Column(String(500), nullable=False) + request_body = Column(Text, nullable=True) + ip_address = Column(String(50), nullable=True) + user_agent = Column(String(500), nullable=True) + created_at = Column(DateTime, default=func.now()) + + # Relationships + impersonation_request = relationship( + "ImpersonationRequest", backref="audit_logs" + ) + + __table_args__ = ( + { + "sqlite_autoincrement": True, + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, + ) diff --git a/backend/app/schemas/impersonation.py b/backend/app/schemas/impersonation.py new file mode 100644 index 00000000..f224633c --- /dev/null +++ b/backend/app/schemas/impersonation.py @@ -0,0 +1,127 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +""" +Pydantic schemas for impersonation feature. +""" + +from datetime import datetime +from typing import List, Literal, Optional + +from pydantic import BaseModel, Field + + +# Status type for impersonation requests +ImpersonationStatus = Literal["pending", "approved", "rejected", "expired", "used"] + + +# Request schemas +class ImpersonationRequestCreate(BaseModel): + """Schema for creating an impersonation request.""" + + target_user_id: int = Field(..., description="ID of the user to impersonate") + + +class ImpersonationRequestResponse(BaseModel): + """Schema for impersonation request response.""" + + id: int + admin_user_id: int + admin_user_name: str + target_user_id: int + target_user_name: str + token: str + status: ImpersonationStatus + confirmation_url: str + expires_at: datetime + approved_at: Optional[datetime] = None + session_expires_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ImpersonationRequestListResponse(BaseModel): + """Schema for paginated list of impersonation requests.""" + + total: int + items: List[ImpersonationRequestResponse] + + +class ImpersonationConfirmInfo(BaseModel): + """Schema for impersonation confirmation page information.""" + + id: int + admin_user_name: str + target_user_name: str + status: ImpersonationStatus + expires_at: datetime + remaining_seconds: int + created_at: datetime + + +class ImpersonationStartResponse(BaseModel): + """Schema for starting an impersonation session.""" + + access_token: str + token_type: str = "bearer" + impersonated_user_id: int + impersonated_user_name: str + session_expires_at: datetime + + +class ImpersonationExitResponse(BaseModel): + """Schema for exiting an impersonation session.""" + + access_token: str + token_type: str = "bearer" + message: str = "Successfully exited impersonation session" + + +# Audit log schemas +class ImpersonationAuditLogResponse(BaseModel): + """Schema for audit log response.""" + + id: int + impersonation_request_id: int + admin_user_id: int + admin_user_name: str + target_user_id: int + target_user_name: str + action: str + method: str + path: str + request_body: Optional[str] = None + ip_address: Optional[str] = None + user_agent: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + + +class ImpersonationAuditLogListResponse(BaseModel): + """Schema for paginated list of audit logs.""" + + total: int + items: List[ImpersonationAuditLogResponse] + + +# User info response with impersonation status +class UserInfoWithImpersonation(BaseModel): + """Extended user info with impersonation status.""" + + id: int + user_name: str + email: Optional[str] = None + role: str + is_impersonating: bool = False + impersonator_name: Optional[str] = None + impersonation_expires_at: Optional[datetime] = None + impersonation_request_id: Optional[int] = None + + class Config: + from_attributes = True diff --git a/backend/app/services/impersonation_service.py b/backend/app/services/impersonation_service.py new file mode 100644 index 00000000..7e0b6a69 --- /dev/null +++ b/backend/app/services/impersonation_service.py @@ -0,0 +1,566 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +""" +Service for handling impersonation requests and sessions. +""" + +import json +import os +import re +import secrets +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Tuple + +from fastapi import HTTPException, status +from sqlalchemy import and_, or_ +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.models.impersonation import ImpersonationAuditLog, ImpersonationRequest +from app.models.user import User + + +class ImpersonationService: + """Service for managing impersonation requests and sessions.""" + + # Configuration with environment variable support + REQUEST_EXPIRY_MINUTES = int(os.getenv("IMPERSONATION_REQUEST_EXPIRY_MINUTES", "30")) + SESSION_EXPIRY_HOURS = int(os.getenv("IMPERSONATION_SESSION_EXPIRY_HOURS", "24")) + AUDIT_RETENTION_DAYS = int(os.getenv("IMPERSONATION_AUDIT_RETENTION_DAYS", "30")) + + # Sensitive fields to redact in audit logs + SENSITIVE_FIELDS = [ + "password", + "token", + "access_token", + "api_key", + "secret", + "credential", + "auth", + ] + + def create_request( + self, db: Session, admin_user: User, target_user_id: int + ) -> ImpersonationRequest: + """ + Create a new impersonation request. + + Args: + db: Database session + admin_user: Admin user creating the request + target_user_id: ID of the user to impersonate + + Returns: + Created impersonation request + """ + # Verify target user exists and is active + target_user = db.query(User).filter(User.id == target_user_id).first() + if not target_user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User with id {target_user_id} not found", + ) + + if not target_user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot impersonate inactive user", + ) + + # Check for existing pending requests for the same target user + existing_request = ( + db.query(ImpersonationRequest) + .filter( + ImpersonationRequest.admin_user_id == admin_user.id, + ImpersonationRequest.target_user_id == target_user_id, + ImpersonationRequest.status == "pending", + ImpersonationRequest.expires_at > datetime.now(timezone.utc), + ) + .first() + ) + + if existing_request: + # Return existing request instead of creating a new one + return existing_request + + # Generate unique token + token = secrets.token_urlsafe(32) + + # Create request + request = ImpersonationRequest( + admin_user_id=admin_user.id, + target_user_id=target_user_id, + token=token, + status="pending", + expires_at=datetime.now(timezone.utc) + + timedelta(minutes=self.REQUEST_EXPIRY_MINUTES), + ) + + db.add(request) + db.commit() + db.refresh(request) + + return request + + def get_request( + self, db: Session, request_id: int, admin_user_id: Optional[int] = None + ) -> ImpersonationRequest: + """ + Get an impersonation request by ID. + + Args: + db: Database session + request_id: Request ID + admin_user_id: If provided, verify the request belongs to this admin + + Returns: + Impersonation request + """ + query = db.query(ImpersonationRequest).filter( + ImpersonationRequest.id == request_id + ) + + if admin_user_id: + query = query.filter(ImpersonationRequest.admin_user_id == admin_user_id) + + request = query.first() + if not request: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Impersonation request not found", + ) + + return request + + def get_request_by_token(self, db: Session, token: str) -> ImpersonationRequest: + """ + Get an impersonation request by token. + + Args: + db: Database session + token: Request token + + Returns: + Impersonation request + """ + request = ( + db.query(ImpersonationRequest) + .filter(ImpersonationRequest.token == token) + .first() + ) + + if not request: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Impersonation request not found", + ) + + return request + + def list_requests( + self, + db: Session, + admin_user_id: int, + page: int = 1, + limit: int = 20, + status_filter: Optional[str] = None, + ) -> Tuple[List[ImpersonationRequest], int]: + """ + List impersonation requests for an admin user. + + Args: + db: Database session + admin_user_id: Admin user ID + page: Page number + limit: Items per page + status_filter: Optional status filter + + Returns: + Tuple of (requests list, total count) + """ + query = db.query(ImpersonationRequest).filter( + ImpersonationRequest.admin_user_id == admin_user_id + ) + + if status_filter: + query = query.filter(ImpersonationRequest.status == status_filter) + + total = query.count() + requests = ( + query.order_by(ImpersonationRequest.created_at.desc()) + .offset((page - 1) * limit) + .limit(limit) + .all() + ) + + return requests, total + + def cancel_request( + self, db: Session, request_id: int, admin_user_id: int + ) -> ImpersonationRequest: + """ + Cancel a pending impersonation request. + + Args: + db: Database session + request_id: Request ID + admin_user_id: Admin user ID + + Returns: + Cancelled request + """ + request = self.get_request(db, request_id, admin_user_id) + + if request.status != "pending": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cannot cancel request with status '{request.status}'", + ) + + request.status = "expired" + db.commit() + db.refresh(request) + + return request + + def approve_request( + self, db: Session, token: str, target_user: User + ) -> ImpersonationRequest: + """ + Approve an impersonation request. + + Args: + db: Database session + token: Request token + target_user: Target user approving the request + + Returns: + Approved request + """ + request = self.get_request_by_token(db, token) + + # Verify the request belongs to the target user + if request.target_user_id != target_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You are not authorized to approve this request", + ) + + # Check request status + if request.status != "pending": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Request is already {request.status}", + ) + + # Check expiration + if request.expires_at < datetime.now(timezone.utc): + request.status = "expired" + db.commit() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Request has expired", + ) + + # Approve the request + request.status = "approved" + request.approved_at = datetime.now(timezone.utc) + request.session_expires_at = datetime.now(timezone.utc) + timedelta( + hours=self.SESSION_EXPIRY_HOURS + ) + db.commit() + db.refresh(request) + + return request + + def reject_request( + self, db: Session, token: str, target_user: User + ) -> ImpersonationRequest: + """ + Reject an impersonation request. + + Args: + db: Database session + token: Request token + target_user: Target user rejecting the request + + Returns: + Rejected request + """ + request = self.get_request_by_token(db, token) + + # Verify the request belongs to the target user + if request.target_user_id != target_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You are not authorized to reject this request", + ) + + # Check request status + if request.status != "pending": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Request is already {request.status}", + ) + + # Reject the request + request.status = "rejected" + db.commit() + db.refresh(request) + + return request + + def start_session( + self, db: Session, request_id: int, admin_user: User + ) -> Tuple[ImpersonationRequest, User]: + """ + Start an impersonation session. + + Args: + db: Database session + request_id: Request ID + admin_user: Admin user starting the session + + Returns: + Tuple of (request, target_user) + """ + request = self.get_request(db, request_id, admin_user.id) + + # Verify request is approved + if request.status != "approved": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cannot start session for request with status '{request.status}'", + ) + + # Check session expiration + if ( + request.session_expires_at + and request.session_expires_at < datetime.now(timezone.utc) + ): + request.status = "expired" + db.commit() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Session has expired", + ) + + # Mark request as used + request.status = "used" + db.commit() + db.refresh(request) + + # Get target user + target_user = ( + db.query(User).filter(User.id == request.target_user_id).first() + ) + + return request, target_user + + def expire_old_requests(self, db: Session) -> int: + """ + Expire old pending requests. + + Args: + db: Database session + + Returns: + Number of expired requests + """ + result = ( + db.query(ImpersonationRequest) + .filter( + ImpersonationRequest.status == "pending", + ImpersonationRequest.expires_at < datetime.now(timezone.utc), + ) + .update({"status": "expired"}) + ) + db.commit() + return result + + def cleanup_old_audit_logs(self, db: Session) -> int: + """ + Delete audit logs older than retention period. + + Args: + db: Database session + + Returns: + Number of deleted logs + """ + cutoff_date = datetime.now(timezone.utc) - timedelta( + days=self.AUDIT_RETENTION_DAYS + ) + result = ( + db.query(ImpersonationAuditLog) + .filter(ImpersonationAuditLog.created_at < cutoff_date) + .delete() + ) + db.commit() + return result + + def log_action( + self, + db: Session, + request_id: int, + admin_user_id: int, + target_user_id: int, + action: str, + method: str, + path: str, + request_body: Optional[str] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + ) -> ImpersonationAuditLog: + """ + Log an action during impersonation session. + + Args: + db: Database session + request_id: Impersonation request ID + admin_user_id: Admin user ID + target_user_id: Target user ID + action: Action type + method: HTTP method + path: Request path + request_body: Request body (will be sanitized) + ip_address: Client IP address + user_agent: Client user agent + + Returns: + Created audit log + """ + # Sanitize request body + sanitized_body = self._sanitize_sensitive_data(request_body) + + log = ImpersonationAuditLog( + impersonation_request_id=request_id, + admin_user_id=admin_user_id, + target_user_id=target_user_id, + action=action, + method=method, + path=path, + request_body=sanitized_body, + ip_address=ip_address, + user_agent=user_agent[:500] if user_agent else None, + ) + + db.add(log) + db.commit() + db.refresh(log) + + return log + + def list_audit_logs( + self, + db: Session, + page: int = 1, + limit: int = 50, + request_id: Optional[int] = None, + admin_user_id: Optional[int] = None, + target_user_id: Optional[int] = None, + ) -> Tuple[List[ImpersonationAuditLog], int]: + """ + List audit logs with filters. + + Args: + db: Database session + page: Page number + limit: Items per page + request_id: Filter by request ID + admin_user_id: Filter by admin user ID + target_user_id: Filter by target user ID + + Returns: + Tuple of (logs list, total count) + """ + query = db.query(ImpersonationAuditLog) + + if request_id: + query = query.filter( + ImpersonationAuditLog.impersonation_request_id == request_id + ) + if admin_user_id: + query = query.filter( + ImpersonationAuditLog.admin_user_id == admin_user_id + ) + if target_user_id: + query = query.filter( + ImpersonationAuditLog.target_user_id == target_user_id + ) + + total = query.count() + logs = ( + query.order_by(ImpersonationAuditLog.created_at.desc()) + .offset((page - 1) * limit) + .limit(limit) + .all() + ) + + return logs, total + + def _sanitize_sensitive_data(self, data: Optional[str]) -> Optional[str]: + """ + Sanitize sensitive data from request body. + + Args: + data: Raw request body + + Returns: + Sanitized request body + """ + if not data: + return None + + try: + # Try to parse as JSON + parsed = json.loads(data) + sanitized = self._redact_sensitive_fields(parsed) + return json.dumps(sanitized) + except (json.JSONDecodeError, TypeError): + # If not JSON, use regex to redact sensitive patterns + result = data + for field in self.SENSITIVE_FIELDS: + pattern = rf'("{field}":\s*")[^"]*(")' + result = re.sub(pattern, r'\1[REDACTED]\2', result, flags=re.IGNORECASE) + return result + + def _redact_sensitive_fields(self, obj: Any) -> Any: + """ + Recursively redact sensitive fields from an object. + + Args: + obj: Object to redact + + Returns: + Redacted object + """ + if isinstance(obj, dict): + return { + k: "[REDACTED]" + if any(s in k.lower() for s in self.SENSITIVE_FIELDS) + else self._redact_sensitive_fields(v) + for k, v in obj.items() + } + elif isinstance(obj, list): + return [self._redact_sensitive_fields(item) for item in obj] + return obj + + def get_confirmation_url(self, token: str) -> str: + """ + Generate confirmation URL for impersonation request. + + Args: + token: Request token + + Returns: + Confirmation URL + """ + frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") + return f"{frontend_url}/impersonate/confirm/{token}" + + +# Singleton instance +impersonation_service = ImpersonationService() diff --git a/frontend/src/apis/impersonation.ts b/frontend/src/apis/impersonation.ts new file mode 100644 index 00000000..9d34709f --- /dev/null +++ b/frontend/src/apis/impersonation.ts @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +import { apiClient } from './client'; +import { + ImpersonationAuditLogListResponse, + ImpersonationConfirmInfo, + ImpersonationExitResponse, + ImpersonationRequest, + ImpersonationRequestCreate, + ImpersonationRequestListResponse, + ImpersonationStartResponse, +} from '@/types/impersonation'; + +// Admin Impersonation API Services +export const impersonationApis = { + // ==================== Admin Endpoints ==================== + + /** + * Create a new impersonation request + */ + async createRequest(data: ImpersonationRequestCreate): Promise { + return apiClient.post('/admin/impersonate/request', data); + }, + + /** + * List impersonation requests for the current admin + */ + async listRequests( + page: number = 1, + limit: number = 20, + statusFilter?: string + ): Promise { + const params = new URLSearchParams(); + params.append('page', String(page)); + params.append('limit', String(limit)); + if (statusFilter) { + params.append('status_filter', statusFilter); + } + return apiClient.get(`/admin/impersonate/requests?${params.toString()}`); + }, + + /** + * Get a specific impersonation request + */ + async getRequest(requestId: number): Promise { + return apiClient.get(`/admin/impersonate/requests/${requestId}`); + }, + + /** + * Cancel a pending impersonation request + */ + async cancelRequest(requestId: number): Promise { + return apiClient.post(`/admin/impersonate/requests/${requestId}/cancel`); + }, + + /** + * Start an impersonation session + */ + async startSession(requestId: number): Promise { + return apiClient.post(`/admin/impersonate/start/${requestId}`); + }, + + /** + * Exit the current impersonation session + */ + async exitSession(): Promise { + return apiClient.post('/admin/impersonate/exit'); + }, + + /** + * List audit logs for impersonation sessions + */ + async listAuditLogs( + page: number = 1, + limit: number = 50, + requestId?: number, + targetUserId?: number + ): Promise { + const params = new URLSearchParams(); + params.append('page', String(page)); + params.append('limit', String(limit)); + if (requestId) { + params.append('request_id', String(requestId)); + } + if (targetUserId) { + params.append('target_user_id', String(targetUserId)); + } + return apiClient.get(`/admin/impersonate/audit-logs?${params.toString()}`); + }, + + // ==================== Public Confirmation Endpoints ==================== + + /** + * Get impersonation request info for confirmation page + */ + async getConfirmInfo(token: string): Promise { + return apiClient.get(`/impersonate/confirm/${token}`); + }, + + /** + * Approve an impersonation request + */ + async approveRequest(token: string): Promise { + return apiClient.post(`/impersonate/confirm/${token}/approve`); + }, + + /** + * Reject an impersonation request + */ + async rejectRequest(token: string): Promise { + return apiClient.post(`/impersonate/confirm/${token}/reject`); + }, +}; diff --git a/frontend/src/app/impersonate/confirm/[token]/page.tsx b/frontend/src/app/impersonate/confirm/[token]/page.tsx new file mode 100644 index 00000000..d4c69c0c --- /dev/null +++ b/frontend/src/app/impersonate/confirm/[token]/page.tsx @@ -0,0 +1,337 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import React, { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Tag } from '@/components/ui/tag'; +import { impersonationApis } from '@/apis/impersonation'; +import { userApis } from '@/apis/user'; +import { ImpersonationConfirmInfo, ImpersonationStatus } from '@/types/impersonation'; +import { useToast } from '@/hooks/use-toast'; +import { + ShieldExclamationIcon, + CheckCircleIcon, + XCircleIcon, + ClockIcon, + ExclamationTriangleIcon, +} from '@heroicons/react/24/outline'; +import { Loader2 } from 'lucide-react'; + +export default function ImpersonationConfirmPage() { + const params = useParams(); + const router = useRouter(); + const { toast } = useToast(); + const token = params.token as string; + + const [info, setInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [processing, setProcessing] = useState(false); + const [remainingTime, setRemainingTime] = useState(''); + const [isAuthenticated, setIsAuthenticated] = useState(false); + + // Check if user is authenticated + useEffect(() => { + const checkAuth = () => { + const authenticated = userApis.isAuthenticated(); + setIsAuthenticated(authenticated); + }; + checkAuth(); + }, []); + + // Fetch request info + useEffect(() => { + const fetchInfo = async () => { + try { + const data = await impersonationApis.getConfirmInfo(token); + setInfo(data); + } catch (error) { + toast({ + variant: 'destructive', + title: 'Failed to load request', + description: (error as Error).message, + }); + } finally { + setLoading(false); + } + }; + + if (token) { + fetchInfo(); + } + }, [token, toast]); + + // Update remaining time countdown + useEffect(() => { + if (!info || info.status !== 'pending') return; + + const updateRemainingTime = () => { + const now = new Date(); + const expiresAt = new Date(info.expires_at); + const diff = expiresAt.getTime() - now.getTime(); + + if (diff <= 0) { + setRemainingTime('Expired'); + return; + } + + const minutes = Math.floor(diff / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); + + setRemainingTime(`${minutes}m ${seconds}s`); + }; + + updateRemainingTime(); + const interval = setInterval(updateRemainingTime, 1000); + + return () => clearInterval(interval); + }, [info]); + + const handleApprove = async () => { + if (!isAuthenticated) { + // Redirect to login with return URL + const returnUrl = encodeURIComponent(window.location.pathname); + router.push(`/login?redirect=${returnUrl}`); + return; + } + + setProcessing(true); + try { + const updatedInfo = await impersonationApis.approveRequest(token); + setInfo(updatedInfo); + toast({ + title: 'Request approved', + description: 'The admin can now impersonate your account.', + }); + } catch (error) { + toast({ + variant: 'destructive', + title: 'Failed to approve request', + description: (error as Error).message, + }); + } finally { + setProcessing(false); + } + }; + + const handleReject = async () => { + if (!isAuthenticated) { + // Redirect to login with return URL + const returnUrl = encodeURIComponent(window.location.pathname); + router.push(`/login?redirect=${returnUrl}`); + return; + } + + setProcessing(true); + try { + const updatedInfo = await impersonationApis.rejectRequest(token); + setInfo(updatedInfo); + toast({ + title: 'Request rejected', + description: 'The impersonation request has been declined.', + }); + } catch (error) { + toast({ + variant: 'destructive', + title: 'Failed to reject request', + description: (error as Error).message, + }); + } finally { + setProcessing(false); + } + }; + + const getStatusConfig = (status: ImpersonationStatus) => { + const configs: Record< + ImpersonationStatus, + { icon: React.ReactNode; color: string; bgColor: string; label: string } + > = { + pending: { + icon: , + color: 'text-amber-600', + bgColor: 'bg-amber-50', + label: 'Pending Approval', + }, + approved: { + icon: , + color: 'text-green-600', + bgColor: 'bg-green-50', + label: 'Approved', + }, + rejected: { + icon: , + color: 'text-red-600', + bgColor: 'bg-red-50', + label: 'Rejected', + }, + expired: { + icon: , + color: 'text-gray-600', + bgColor: 'bg-gray-50', + label: 'Expired', + }, + used: { + icon: , + color: 'text-blue-600', + bgColor: 'bg-blue-50', + label: 'Used', + }, + }; + return configs[status]; + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!info) { + return ( +
+ + +

Request Not Found

+

+ This impersonation request does not exist or has been removed. +

+
+
+ ); + } + + const statusConfig = getStatusConfig(info.status); + + return ( +
+ + {/* Header */} +
+
+ {statusConfig.icon} +
+

Impersonation Request

+ + {statusConfig.label} + +
+ + {/* Info */} +
+
+ Admin + {info.admin_user_name} +
+
+ Target User + {info.target_user_name} +
+ {info.status === 'pending' && ( +
+ Expires In + {remainingTime} +
+ )} +
+ Requested At + + {new Date(info.created_at).toLocaleString()} + +
+
+ + {/* Warning */} + {info.status === 'pending' && ( +
+
+ +
+

Security Notice

+

+ By approving this request, you allow the admin to access your account + and perform actions on your behalf. The session will be limited to 24 hours + and all actions will be logged. +

+
+
+
+ )} + + {/* Not Authenticated Warning */} + {info.status === 'pending' && !isAuthenticated && ( +
+

+ You need to log in as {info.target_user_name} to approve or reject this request. +

+
+ )} + + {/* Actions */} + {info.status === 'pending' && ( +
+ + +
+ )} + + {/* Status Messages */} + {info.status === 'approved' && ( +
+

+ This request has been approved. The admin can now access your account. +

+
+ )} + + {info.status === 'rejected' && ( +
+

This request has been rejected.

+
+ )} + + {info.status === 'expired' && ( +
+

This request has expired.

+
+ )} + + {info.status === 'used' && ( +
+

+ This request has been used. The impersonation session may be active. +

+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/common/ImpersonationBanner.tsx b/frontend/src/components/common/ImpersonationBanner.tsx new file mode 100644 index 00000000..8a099162 --- /dev/null +++ b/frontend/src/components/common/ImpersonationBanner.tsx @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import React, { useEffect, useState } from 'react'; +import { useUser } from '@/features/common/UserContext'; +import { useTranslation } from '@/hooks/useTranslation'; +import { Button } from '@/components/ui/button'; +import { ExclamationTriangleIcon, XMarkIcon } from '@heroicons/react/24/outline'; + +/** + * Impersonation Banner Component + * + * Displays a warning banner at the top of the page when an admin is + * impersonating another user. Shows the impersonated user's name, + * remaining session time, and an exit button. + */ +const ImpersonationBanner: React.FC = () => { + const { t } = useTranslation('admin'); + const { user, isImpersonating, impersonatorName, impersonationExpiresAt, exitImpersonation } = + useUser(); + const [remainingTime, setRemainingTime] = useState(''); + const [isExiting, setIsExiting] = useState(false); + + // Update remaining time countdown + useEffect(() => { + if (!isImpersonating || !impersonationExpiresAt) { + return; + } + + const updateRemainingTime = () => { + const now = new Date(); + const expiresAt = new Date(impersonationExpiresAt); + const diff = expiresAt.getTime() - now.getTime(); + + if (diff <= 0) { + setRemainingTime('00:00:00'); + // Session expired, trigger exit + exitImpersonation(); + return; + } + + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); + + setRemainingTime( + `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` + ); + }; + + updateRemainingTime(); + const interval = setInterval(updateRemainingTime, 1000); + + return () => clearInterval(interval); + }, [isImpersonating, impersonationExpiresAt, exitImpersonation]); + + // Don't render if not impersonating + if (!isImpersonating || !user) { + return null; + } + + const handleExit = async () => { + setIsExiting(true); + try { + await exitImpersonation(); + } finally { + setIsExiting(false); + } + }; + + return ( +
+
+
+ + + {t('impersonation.banner.viewing_as', { name: user.user_name })} + + | + + {t('impersonation.banner.remaining_time')}: {remainingTime} + + {impersonatorName && ( + <> + | + + {t('impersonation.banner.admin')}: {impersonatorName} + + + )} +
+ +
+
+ ); +}; + +export default ImpersonationBanner; diff --git a/frontend/src/features/admin/components/ImpersonateDialog.tsx b/frontend/src/features/admin/components/ImpersonateDialog.tsx new file mode 100644 index 00000000..98ffb3ff --- /dev/null +++ b/frontend/src/features/admin/components/ImpersonateDialog.tsx @@ -0,0 +1,300 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import React, { useEffect, useState, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Tag } from '@/components/ui/tag'; +import { useToast } from '@/hooks/use-toast'; +import { useTranslation } from '@/hooks/useTranslation'; +import { impersonationApis } from '@/apis/impersonation'; +import { ImpersonationRequest, ImpersonationStatus } from '@/types/impersonation'; +import { AdminUser } from '@/apis/admin'; +import { + ClipboardDocumentIcon, + CheckIcon, + ArrowPathIcon, + PlayIcon, +} from '@heroicons/react/24/outline'; +import { Loader2 } from 'lucide-react'; + +interface ImpersonateDialogProps { + isOpen: boolean; + onClose: () => void; + targetUser: AdminUser | null; +} + +const ImpersonateDialog: React.FC = ({ isOpen, onClose, targetUser }) => { + const { t } = useTranslation('admin'); + const { toast } = useToast(); + const [request, setRequest] = useState(null); + const [loading, setLoading] = useState(false); + const [copied, setCopied] = useState(false); + const [remainingTime, setRemainingTime] = useState(''); + const [startingSession, setStartingSession] = useState(false); + + // Create impersonation request when dialog opens + const createRequest = useCallback(async () => { + if (!targetUser) return; + + setLoading(true); + try { + const newRequest = await impersonationApis.createRequest({ + target_user_id: targetUser.id, + }); + setRequest(newRequest); + } catch (error) { + toast({ + variant: 'destructive', + title: t('impersonation.errors.create_failed'), + description: (error as Error).message, + }); + onClose(); + } finally { + setLoading(false); + } + }, [targetUser, toast, t, onClose]); + + // Refresh request status + const refreshStatus = useCallback(async () => { + if (!request) return; + + try { + const updatedRequest = await impersonationApis.getRequest(request.id); + setRequest(updatedRequest); + } catch (error) { + console.error('Failed to refresh request status:', error); + } + }, [request]); + + // Create request when dialog opens + useEffect(() => { + if (isOpen && targetUser) { + createRequest(); + } else { + setRequest(null); + setCopied(false); + } + }, [isOpen, targetUser, createRequest]); + + // Poll for status updates when pending + useEffect(() => { + if (!request || request.status !== 'pending') return; + + const interval = setInterval(refreshStatus, 5000); // Poll every 5 seconds + return () => clearInterval(interval); + }, [request, refreshStatus]); + + // Update remaining time countdown + useEffect(() => { + if (!request || !request.expires_at) return; + + const updateRemainingTime = () => { + const now = new Date(); + const expiresAt = new Date(request.expires_at); + const diff = expiresAt.getTime() - now.getTime(); + + if (diff <= 0) { + setRemainingTime('00:00'); + return; + } + + const minutes = Math.floor(diff / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); + + setRemainingTime(`${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`); + }; + + updateRemainingTime(); + const interval = setInterval(updateRemainingTime, 1000); + + return () => clearInterval(interval); + }, [request]); + + const handleCopyLink = async () => { + if (!request?.confirmation_url) return; + + try { + await navigator.clipboard.writeText(request.confirmation_url); + setCopied(true); + toast({ title: t('impersonation.link_copied') }); + setTimeout(() => setCopied(false), 2000); + } catch { + toast({ + variant: 'destructive', + title: t('impersonation.copy_failed'), + }); + } + }; + + const handleStartSession = async () => { + if (!request || request.status !== 'approved') return; + + setStartingSession(true); + try { + const response = await impersonationApis.startSession(request.id); + // Store the impersonation token + localStorage.setItem('token', response.access_token); + toast({ + title: t('impersonation.session_started', { name: response.impersonated_user_name }), + }); + // Reload the page to refresh user context + window.location.href = '/'; + } catch (error) { + toast({ + variant: 'destructive', + title: t('impersonation.errors.start_failed'), + description: (error as Error).message, + }); + } finally { + setStartingSession(false); + } + }; + + const handleCancel = async () => { + if (!request || request.status !== 'pending') { + onClose(); + return; + } + + try { + await impersonationApis.cancelRequest(request.id); + toast({ title: t('impersonation.request_cancelled') }); + } catch (error) { + console.error('Failed to cancel request:', error); + } + onClose(); + }; + + const getStatusTag = (status: ImpersonationStatus) => { + const statusConfig: Record = { + pending: { variant: 'warning', label: t('impersonation.status.pending') }, + approved: { variant: 'success', label: t('impersonation.status.approved') }, + rejected: { variant: 'error', label: t('impersonation.status.rejected') }, + expired: { variant: 'default', label: t('impersonation.status.expired') }, + used: { variant: 'info', label: t('impersonation.status.used') }, + }; + + const config = statusConfig[status]; + return {config.label}; + }; + + return ( + !open && handleCancel()}> + + + {t('impersonation.dialog.title')} + + {t('impersonation.dialog.description', { name: targetUser?.user_name })} + + + + {loading ? ( +
+ +
+ ) : request ? ( +
+ {/* Target User Info */} +
+
+

{t('impersonation.dialog.target_user')}

+

{request.target_user_name}

+
+ {getStatusTag(request.status)} +
+ + {/* Confirmation Link */} + {request.status === 'pending' && ( + <> +
+ +
+ + +
+

+ {t('impersonation.dialog.link_instruction')} +

+
+ + {/* Countdown */} +
+ {t('impersonation.dialog.expires_in')} + {remainingTime} +
+ + {/* Refresh Button */} +
+ +
+ + )} + + {/* Approved State */} + {request.status === 'approved' && ( +
+
+

{t('impersonation.dialog.approved_message')}

+

{t('impersonation.dialog.click_start')}

+
+ +
+ )} + + {/* Rejected State */} + {request.status === 'rejected' && ( +
+

{t('impersonation.dialog.rejected_message')}

+
+ )} + + {/* Expired State */} + {request.status === 'expired' && ( +
+

{t('impersonation.dialog.expired_message')}

+
+ )} +
+ ) : null} + + + + +
+
+ ); +}; + +export default ImpersonateDialog; diff --git a/frontend/src/features/admin/components/UserList.tsx b/frontend/src/features/admin/components/UserList.tsx index ed0b6c35..313f514c 100644 --- a/frontend/src/features/admin/components/UserList.tsx +++ b/frontend/src/features/admin/components/UserList.tsx @@ -24,6 +24,7 @@ import { KeyIcon, NoSymbolIcon, CheckCircleIcon, + IdentificationIcon, } from '@heroicons/react/24/outline'; import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/solid'; import { Loader2 } from 'lucide-react'; @@ -49,6 +50,7 @@ import { } from '@/components/ui/dialog'; import { adminApis, AdminUser, AdminUserCreate, AdminUserUpdate, UserRole } from '@/apis/admin'; import UnifiedAddButton from '@/components/common/UnifiedAddButton'; +import ImpersonateDialog from './ImpersonateDialog'; const UserList: React.FC = () => { const { t } = useTranslation('admin'); @@ -77,6 +79,7 @@ const UserList: React.FC = () => { const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isResetPasswordDialogOpen, setIsResetPasswordDialogOpen] = useState(false); + const [isImpersonateDialogOpen, setIsImpersonateDialogOpen] = useState(false); const [selectedUser, setSelectedUser] = useState(null); // Form states @@ -367,6 +370,19 @@ const UserList: React.FC = () => {
+
); }; diff --git a/frontend/src/features/common/UserContext.tsx b/frontend/src/features/common/UserContext.tsx index b47ad08c..9ae362fe 100644 --- a/frontend/src/features/common/UserContext.tsx +++ b/frontend/src/features/common/UserContext.tsx @@ -5,6 +5,7 @@ 'use client'; import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react'; import { userApis } from '@/apis/user'; +import { impersonationApis } from '@/apis/impersonation'; import { User } from '@/types/api'; import { useRouter } from 'next/navigation'; import { paths } from '@/config/paths'; @@ -17,6 +18,11 @@ interface UserContextType { logout: () => void; refresh: () => Promise; login: (data: { user_name: string; password: string }) => Promise; + // Impersonation support + isImpersonating: boolean; + impersonatorName: string | null; + impersonationExpiresAt: Date | null; + exitImpersonation: () => Promise; } const UserContext = createContext({ user: null, @@ -24,14 +30,84 @@ const UserContext = createContext({ logout: () => {}, refresh: async () => {}, login: async () => {}, + isImpersonating: false, + impersonatorName: null, + impersonationExpiresAt: null, + exitImpersonation: async () => {}, }); export const UserProvider = ({ children }: { children: ReactNode }) => { const { toast } = useToast(); const router = useRouter(); const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); + // Impersonation state + const [isImpersonating, setIsImpersonating] = useState(false); + const [impersonatorName, setImpersonatorName] = useState(null); + const [impersonationExpiresAt, setImpersonationExpiresAt] = useState(null); // Using antd message.error for unified error handling, no local error state needed + // Parse JWT token to extract impersonation info + const parseTokenForImpersonation = () => { + if (typeof window === 'undefined') return; + + const token = localStorage.getItem('token'); + if (!token) { + setIsImpersonating(false); + setImpersonatorName(null); + setImpersonationExpiresAt(null); + return; + } + + try { + // Decode JWT payload (base64) + const parts = token.split('.'); + if (parts.length !== 3) return; + + const payload = JSON.parse(atob(parts[1])); + + if (payload.is_impersonating) { + setIsImpersonating(true); + setImpersonatorName(payload.impersonator_name || null); + // Convert exp timestamp to Date + if (payload.exp) { + setImpersonationExpiresAt(new Date(payload.exp * 1000)); + } + } else { + setIsImpersonating(false); + setImpersonatorName(null); + setImpersonationExpiresAt(null); + } + } catch { + // Invalid token format, ignore + setIsImpersonating(false); + setImpersonatorName(null); + setImpersonationExpiresAt(null); + } + }; + + const exitImpersonation = async () => { + try { + const response = await impersonationApis.exitSession(); + // Store new token + localStorage.setItem('token', response.access_token); + // Reset impersonation state + setIsImpersonating(false); + setImpersonatorName(null); + setImpersonationExpiresAt(null); + // Refresh user data + await fetchUser(); + // Redirect to admin page + router.push('/admin'); + toast({ title: response.message }); + } catch (error) { + toast({ + variant: 'destructive', + title: 'Failed to exit impersonation', + description: (error as Error).message, + }); + } + }; + const redirectToLogin = () => { const loginPath = paths.auth.login.getHref(); if (typeof window === 'undefined') { @@ -73,6 +149,8 @@ export const UserProvider = ({ children }: { children: ReactNode }) => { const userData = await userApis.getCurrentUser(); setUser(userData); + // Parse impersonation info from token + parseTokenForImpersonation(); } catch (error) { console.error('UserContext: Failed to fetch user information:', error as Error); toast({ @@ -139,7 +217,19 @@ export const UserProvider = ({ children }: { children: ReactNode }) => { }; return ( - + {children} ); diff --git a/frontend/src/i18n/locales/en/admin.json b/frontend/src/i18n/locales/en/admin.json index 33200499..e08d8ccc 100644 --- a/frontend/src/i18n/locales/en/admin.json +++ b/frontend/src/i18n/locales/en/admin.json @@ -159,5 +159,44 @@ "title": "Access Denied", "message": "You do not have permission to access this page. Only administrators can view this content.", "go_home": "Go to Home" + }, + "impersonation": { + "impersonate_user": "Impersonate User", + "dialog": { + "title": "Impersonate User", + "description": "Create an impersonation request for {{name}}", + "target_user": "Target User", + "confirmation_link": "Confirmation Link", + "link_instruction": "Send this link to the user. They must click it to approve the impersonation.", + "expires_in": "Expires In", + "refresh_status": "Refresh Status", + "approved_message": "Request Approved!", + "click_start": "Click below to start the impersonation session.", + "start_session": "Start Impersonation", + "rejected_message": "The user has rejected this request.", + "expired_message": "This request has expired." + }, + "status": { + "pending": "Pending", + "approved": "Approved", + "rejected": "Rejected", + "expired": "Expired", + "used": "Used" + }, + "banner": { + "viewing_as": "You are viewing as {{name}}", + "remaining_time": "Remaining", + "admin": "Admin", + "exit": "Exit Impersonation", + "exiting": "Exiting..." + }, + "link_copied": "Link copied to clipboard", + "copy_failed": "Failed to copy link", + "request_cancelled": "Request cancelled", + "session_started": "Now impersonating {{name}}", + "errors": { + "create_failed": "Failed to create impersonation request", + "start_failed": "Failed to start impersonation session" + } } } diff --git a/frontend/src/i18n/locales/zh-CN/admin.json b/frontend/src/i18n/locales/zh-CN/admin.json index 435f4d0e..701aa060 100644 --- a/frontend/src/i18n/locales/zh-CN/admin.json +++ b/frontend/src/i18n/locales/zh-CN/admin.json @@ -159,5 +159,44 @@ "title": "访问被拒绝", "message": "您没有权限访问此页面,只有管理员可以查看此内容。", "go_home": "返回首页" + }, + "impersonation": { + "impersonate_user": "模拟登录", + "dialog": { + "title": "模拟用户登录", + "description": "为 {{name}} 创建模拟登录请求", + "target_user": "目标用户", + "confirmation_link": "确认链接", + "link_instruction": "将此链接发送给用户,用户点击后确认授权即可开始模拟。", + "expires_in": "有效期", + "refresh_status": "刷新状态", + "approved_message": "请求已批准!", + "click_start": "点击下方按钮开始模拟登录会话。", + "start_session": "开始模拟", + "rejected_message": "用户已拒绝此请求。", + "expired_message": "此请求已过期。" + }, + "status": { + "pending": "待处理", + "approved": "已批准", + "rejected": "已拒绝", + "expired": "已过期", + "used": "已使用" + }, + "banner": { + "viewing_as": "您正在以 {{name}} 的身份浏览", + "remaining_time": "剩余时间", + "admin": "管理员", + "exit": "退出模拟", + "exiting": "退出中..." + }, + "link_copied": "链接已复制到剪贴板", + "copy_failed": "复制链接失败", + "request_cancelled": "请求已取消", + "session_started": "正在模拟用户 {{name}}", + "errors": { + "create_failed": "创建模拟请求失败", + "start_failed": "启动模拟会话失败" + } } } diff --git a/frontend/src/types/impersonation.ts b/frontend/src/types/impersonation.ts new file mode 100644 index 00000000..b18b70f5 --- /dev/null +++ b/frontend/src/types/impersonation.ts @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +// Impersonation Types + +export type ImpersonationStatus = 'pending' | 'approved' | 'rejected' | 'expired' | 'used'; + +// Impersonation Request +export interface ImpersonationRequest { + id: number; + admin_user_id: number; + admin_user_name: string; + target_user_id: number; + target_user_name: string; + token: string; + status: ImpersonationStatus; + confirmation_url: string; + expires_at: string; + approved_at: string | null; + session_expires_at: string | null; + created_at: string; + updated_at: string; +} + +export interface ImpersonationRequestListResponse { + total: number; + items: ImpersonationRequest[]; +} + +export interface ImpersonationRequestCreate { + target_user_id: number; +} + +// Impersonation Confirmation +export interface ImpersonationConfirmInfo { + id: number; + admin_user_name: string; + target_user_name: string; + status: ImpersonationStatus; + expires_at: string; + remaining_seconds: number; + created_at: string; +} + +// Impersonation Session +export interface ImpersonationStartResponse { + access_token: string; + token_type: string; + impersonated_user_id: number; + impersonated_user_name: string; + session_expires_at: string; +} + +export interface ImpersonationExitResponse { + access_token: string; + token_type: string; + message: string; +} + +// Audit Logs +export interface ImpersonationAuditLog { + id: number; + impersonation_request_id: number; + admin_user_id: number; + admin_user_name: string; + target_user_id: number; + target_user_name: string; + action: string; + method: string; + path: string; + request_body: string | null; + ip_address: string | null; + user_agent: string | null; + created_at: string; +} + +export interface ImpersonationAuditLogListResponse { + total: number; + items: ImpersonationAuditLog[]; +} + +// Impersonation Info (from JWT token) +export interface ImpersonationInfo { + isImpersonating: boolean; + impersonatorId?: number; + impersonatorName?: string; + impersonationRequestId?: number; + impersonationExpiresAt?: Date; +}