외부 계정 수동 등록 허용

This commit is contained in:
Yun Chan 2026-06-28 11:31:07 +09:00
parent bd389a97cc
commit 8ed185ce6c
9 changed files with 3450 additions and 1484 deletions

File diff suppressed because it is too large Load diff

View file

@ -10,16 +10,21 @@ from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from ..auth_types import AccountStatus, RoleName
from ..auth_sessions import (
ManagedUserPatch,
active_session_count,
deactivate_managed_user,
get_managed_user,
has_admin_access,
is_super_admin_email,
list_managed_users,
update_managed_user,
upsert_managed_user,
)
from ..config import settings
from ..db import acquire, get_pool, healthcheck
from ..deps import Principal, Role, require_role
from ..deps import Principal, require_admin_access
from ..engine_client import engine_client
from ..runtime_policy import require_runtime_fallback_allowed
from ..services.voice import voice_service
@ -28,9 +33,19 @@ from ..store import store
router = APIRouter(prefix="/admin", tags=["admin"])
AdminPrincipal = Annotated[Principal, Depends(require_role(Role.ADMIN))]
AdminPrincipal = Annotated[Principal, Depends(require_admin_access())]
HealthStatus = Literal["ok", "degraded", "down"]
UsageBudgetStatus = Literal["disabled", "ok", "warn", "exceeded"]
TicketCategory = Literal[
"account_access",
"session_review",
"voice_browser",
"content_scenario",
"safety",
"other",
]
TicketPriority = Literal["low", "normal", "high", "urgent"]
TicketStatus = Literal["open", "triaged", "in_progress", "resolved", "closed"]
class AdminServiceHealth(BaseModel):
@ -79,6 +94,84 @@ class AdminUsageResponse(BaseModel):
by_provider: list[AdminUsageBreakdown]
class AdminHealthEvent(BaseModel):
event_id: int
observed_at: float
overall_status: HealthStatus
service_key: str
service_name: str
service_status: HealthStatus
detail: str
metric: str
load: float
class AdminUptimeServiceSummary(BaseModel):
service_key: str
service_name: str
samples: int
ok_samples: int
degraded_samples: int
down_samples: int
latest_status: HealthStatus
latest_observed_at: float | None = None
class AdminUptimeResponse(BaseModel):
source: Literal["database", "unavailable"]
durable: bool
window_hours: int
generated_at: float
sample_count: int
ok_ratio: float
degraded_events: int
down_events: int
last_down_at: float | None = None
services: list[AdminUptimeServiceSummary]
events: list[AdminHealthEvent]
class AdminTicketReporter(BaseModel):
user_id: str | None = None
email: str
display_name: str
role: str
class AdminSupportTicketResponse(BaseModel):
ticket_id: str
reporter: AdminTicketReporter
category: TicketCategory
priority: TicketPriority
status: TicketStatus
subject: str
body: str
source_path: str
assigned_group: str
resolution_note: str
created_at: float
updated_at: float
resolved_at: float | None = None
class AdminTicketSummary(BaseModel):
total: int
open_count: int
high_priority_count: int
stale_count: int
by_status: dict[str, int]
by_category: dict[str, int]
by_priority: dict[str, int]
class AdminTicketsResponse(BaseModel):
source: Literal["database", "unavailable"]
durable: bool
generated_at: float
tickets: list[AdminSupportTicketResponse]
summary: AdminTicketSummary
class AdminEngineConfigResponse(BaseModel):
engine_mode: str
engine_url: str
@ -95,7 +188,11 @@ class AdminEngineConfigPatch(BaseModel):
model: str | None = None
RoleName = Literal["learner", "teacher", "admin"]
class AdminTicketPatch(BaseModel):
status: TicketStatus | None = None
priority: TicketPriority | None = None
assigned_group: str | None = Field(default=None, max_length=120)
resolution_note: str | None = Field(default=None, max_length=2000)
class AdminUserResponse(BaseModel):
@ -103,6 +200,9 @@ class AdminUserResponse(BaseModel):
email: str
display_name: str
role: RoleName
admin_access: bool
super_admin: bool = False
account_status: AccountStatus
cohort_ids: list[str]
affiliation: str
active_sessions: int
@ -120,6 +220,8 @@ class AdminUsersResponse(BaseModel):
class AdminUserPatch(BaseModel):
display_name: str | None = Field(default=None, min_length=1, max_length=80)
role: RoleName | None = None
admin_access: bool | None = None
account_status: AccountStatus | None = None
affiliation: str | None = Field(default=None, max_length=120)
cohort_ids: list[str] | None = None
@ -289,7 +391,10 @@ async def _usage_from_database(window_days: int) -> AdminUsageResponse:
OR cost_usd IS NOT NULL
)
GROUP BY 1, 2
ORDER BY cost_usd DESC, tokens_in + tokens_out DESC, turns DESC
ORDER BY
cost_usd DESC,
COALESCE(SUM(tokens_in), 0) + COALESCE(SUM(tokens_out), 0) DESC,
turns DESC
LIMIT 12
""",
window_days,
@ -321,6 +426,180 @@ async def _usage_from_database(window_days: int) -> AdminUsageResponse:
)
async def _record_health_events(
*,
principal: Principal,
overall_status: HealthStatus,
environment: str,
engine_mode: str,
services: list[AdminServiceHealth],
) -> None:
if not services:
return
try:
async with acquire(role="admin", user_id=principal.user_id) as conn:
await conn.executemany(
"""
INSERT INTO app.admin_health_event (
overall_status,
environment,
engine_mode,
service_key,
service_name,
service_status,
detail,
metric,
load,
captured_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::uuid)
""",
[
(
overall_status,
environment,
engine_mode,
service.key,
service.name,
service.status,
service.detail,
service.metric,
service.load,
principal.user_id,
)
for service in services
],
)
except Exception:
# 헬스 화면 자체가 장애 확인 경로라, 이력 적재 실패가 응답을 막으면 안 된다.
return
async def _uptime_from_database(window_hours: int) -> AdminUptimeResponse:
async with acquire(role="admin") as conn:
rows = await conn.fetch(
"""
SELECT
id,
observed_at,
overall_status,
service_key,
service_name,
service_status,
detail,
metric,
load
FROM app.admin_health_event
WHERE observed_at >= now() - ($1::int * interval '1 hour')
ORDER BY observed_at DESC, id DESC
LIMIT 240
""",
window_hours,
)
events = [_health_event_from_row(row) for row in rows]
service_buckets: dict[str, AdminUptimeServiceSummary] = {}
for event in events:
current = service_buckets.get(event.service_key)
if current is None:
current = AdminUptimeServiceSummary(
service_key=event.service_key,
service_name=event.service_name,
samples=0,
ok_samples=0,
degraded_samples=0,
down_samples=0,
latest_status=event.service_status,
latest_observed_at=event.observed_at,
)
service_buckets[event.service_key] = current
current.samples += 1
if event.service_status == "ok":
current.ok_samples += 1
elif event.service_status == "degraded":
current.degraded_samples += 1
else:
current.down_samples += 1
ok_samples = sum(1 for event in events if event.service_status == "ok")
sample_count = len(events)
down_times = [event.observed_at for event in events if event.service_status == "down"]
return AdminUptimeResponse(
source="database",
durable=True,
window_hours=window_hours,
generated_at=time.time(),
sample_count=sample_count,
ok_ratio=round(ok_samples / sample_count, 4) if sample_count else 0.0,
degraded_events=sum(1 for event in events if event.service_status == "degraded"),
down_events=sum(1 for event in events if event.service_status == "down"),
last_down_at=max(down_times) if down_times else None,
services=sorted(
service_buckets.values(),
key=lambda item: (item.latest_status != "down", item.latest_status != "degraded", item.service_key),
),
events=events,
)
async def _tickets_from_database(
*,
ticket_status: TicketStatus | None,
window_days: int,
) -> AdminTicketsResponse:
async with acquire(role="admin") as conn:
rows = await conn.fetch(
"""
SELECT
id,
reporter_id,
reporter_email,
reporter_name,
reporter_role,
category,
priority,
status,
subject,
body,
source_path,
assigned_group,
resolution_note,
created_at,
updated_at,
resolved_at
FROM app.support_ticket
WHERE ($1::text IS NULL OR status = $1)
AND created_at >= now() - ($2::int * interval '1 day')
ORDER BY
CASE
WHEN status = 'open' THEN 0
WHEN status = 'triaged' THEN 1
WHEN status = 'in_progress' THEN 2
WHEN status = 'resolved' THEN 3
ELSE 4
END,
CASE
WHEN priority = 'urgent' THEN 0
WHEN priority = 'high' THEN 1
WHEN priority = 'normal' THEN 2
ELSE 3
END,
updated_at DESC
LIMIT 120
""",
ticket_status,
window_days,
)
tickets = [_ticket_from_row(row) for row in rows]
return AdminTicketsResponse(
source="database",
durable=True,
generated_at=time.time(),
tickets=tickets,
summary=_ticket_summary(tickets),
)
def _usage_from_runtime_store(window_days: int) -> AdminUsageResponse:
window_start = time.time() - (window_days * 86400)
total_turns = 0
@ -404,6 +683,8 @@ class AdminUserCreate(BaseModel):
email: str = Field(..., min_length=3, max_length=254)
display_name: str = Field(..., min_length=1, max_length=80)
role: RoleName = "learner"
admin_access: bool = False
account_status: AccountStatus = "approved"
affiliation: str | None = Field(default=None, max_length=120)
cohort_ids: list[str] = Field(default_factory=list)
@ -425,9 +706,6 @@ def _normalize_email(value: str) -> str:
local, domain = email.rsplit("@", 1)
if not local or not domain:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="email is invalid")
allowed = {item.strip().lower().lstrip("@") for item in settings.auth_allowed_email_domains if item.strip()}
if domain not in allowed:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email domain is not allowed")
return email
@ -469,6 +747,17 @@ def _updated_at_ts(value: datetime | None) -> float | None:
return value.timestamp()
def _row_ts(value: object) -> float | None:
if value is None:
return None
if isinstance(value, datetime):
return _updated_at_ts(value)
try:
return float(value)
except (TypeError, ValueError):
return None
def _engine_config_from_row(row) -> AdminEngineConfigResponse:
return AdminEngineConfigResponse(
engine_mode=_normalize_engine_mode(row["engine_mode"]),
@ -481,6 +770,108 @@ def _engine_config_from_row(row) -> AdminEngineConfigResponse:
)
def _health_event_from_row(row) -> AdminHealthEvent:
return AdminHealthEvent(
event_id=int(row["id"]),
observed_at=_row_ts(row["observed_at"]) or 0.0,
overall_status=row["overall_status"],
service_key=row["service_key"],
service_name=row["service_name"],
service_status=row["service_status"],
detail=row["detail"],
metric=row["metric"],
load=_clamp01(float(row["load"] or 0.0)),
)
def _ticket_from_row(row) -> AdminSupportTicketResponse:
return AdminSupportTicketResponse(
ticket_id=str(row["id"]),
reporter=AdminTicketReporter(
user_id=str(row["reporter_id"]) if row["reporter_id"] else None,
email=row["reporter_email"],
display_name=row["reporter_name"],
role=row["reporter_role"],
),
category=row["category"],
priority=row["priority"],
status=row["status"],
subject=row["subject"],
body=row["body"],
source_path=row["source_path"],
assigned_group=row["assigned_group"],
resolution_note=row["resolution_note"],
created_at=_row_ts(row["created_at"]) or 0.0,
updated_at=_row_ts(row["updated_at"]) or 0.0,
resolved_at=_row_ts(row["resolved_at"]),
)
def _empty_ticket_summary() -> AdminTicketSummary:
return AdminTicketSummary(
total=0,
open_count=0,
high_priority_count=0,
stale_count=0,
by_status={},
by_category={},
by_priority={},
)
def _ticket_summary(tickets: list[AdminSupportTicketResponse]) -> AdminTicketSummary:
now = time.time()
by_status: dict[str, int] = {}
by_category: dict[str, int] = {}
by_priority: dict[str, int] = {}
active_tickets = [ticket for ticket in tickets if ticket.status not in {"resolved", "closed"}]
for ticket in tickets:
by_status[ticket.status] = by_status.get(ticket.status, 0) + 1
by_category[ticket.category] = by_category.get(ticket.category, 0) + 1
by_priority[ticket.priority] = by_priority.get(ticket.priority, 0) + 1
return AdminTicketSummary(
total=len(tickets),
open_count=len(active_tickets),
high_priority_count=sum(
1 for ticket in active_tickets if ticket.priority in {"high", "urgent"}
),
stale_count=sum(
1
for ticket in active_tickets
if now - ticket.updated_at > 86400
),
by_status=by_status,
by_category=by_category,
by_priority=by_priority,
)
def _unavailable_uptime(window_hours: int) -> AdminUptimeResponse:
return AdminUptimeResponse(
source="unavailable",
durable=False,
window_hours=window_hours,
generated_at=time.time(),
sample_count=0,
ok_ratio=0.0,
degraded_events=0,
down_events=0,
last_down_at=None,
services=[],
events=[],
)
def _unavailable_tickets() -> AdminTicketsResponse:
return AdminTicketsResponse(
source="unavailable",
durable=False,
generated_at=time.time(),
tickets=[],
summary=_empty_ticket_summary(),
)
async def _current_engine_config() -> AdminEngineConfigResponse:
if _ENGINE_CONFIG is not None:
return _ENGINE_CONFIG
@ -496,9 +887,9 @@ async def _current_engine_config() -> AdminEngineConfigResponse:
)
if row is not None:
return _engine_config_from_row(row)
return _default_engine_config()
except Exception:
require_runtime_fallback_allowed("admin engine config")
require_runtime_fallback_allowed("admin engine config")
return _default_engine_config()
@ -527,12 +918,42 @@ def _engine_unavailable_detail(detail: str) -> str:
return detail
def _requires_super_admin_for_privilege_change(
*,
current_role: RoleName | None,
next_role: RoleName | None,
current_admin_access: bool,
next_admin_access: bool | None,
) -> bool:
if next_admin_access is not None and next_admin_access != current_admin_access:
return True
if next_role == "admin" and current_role != "admin":
return True
if current_role == "admin" and next_role is not None and next_role != "admin":
return True
return False
def _assert_super_admin(principal: Principal) -> None:
if not principal.super_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="super admin required")
def _assert_super_admin_target_mutable(email: str, admin_access: bool | None) -> None:
if is_super_admin_email(email) and admin_access is False:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="cannot revoke super admin access")
async def _admin_user_response(user, *, durable: bool) -> AdminUserResponse:
effective_admin_access = has_admin_access(user.email, user.role, user.admin_access)
return AdminUserResponse(
user_id=user.user_id,
email=user.email,
display_name=user.display_name,
role=user.role,
admin_access=effective_admin_access,
super_admin=is_super_admin_email(user.email),
account_status=user.account_status,
cohort_ids=user.cohort_ids,
affiliation=user.affiliation,
active_sessions=await active_session_count(user.user_id),
@ -624,12 +1045,20 @@ async def admin_health(principal: AdminPrincipal) -> AdminHealthResponse:
),
]
return AdminHealthResponse(
response = AdminHealthResponse(
status=_overall_status(services),
environment=settings.environment,
engine_mode=current_engine.engine_mode,
services=services,
)
await _record_health_events(
principal=principal,
overall_status=response.status,
environment=response.environment,
engine_mode=response.engine_mode,
services=response.services,
)
return response
@router.get("/usage", response_model=AdminUsageResponse)
@ -645,6 +1074,91 @@ async def admin_usage(
return _usage_from_runtime_store(window_days)
@router.get("/uptime", response_model=AdminUptimeResponse)
async def admin_uptime(
principal: AdminPrincipal,
window_hours: Annotated[int, Query(ge=1, le=720)] = 24,
) -> AdminUptimeResponse:
"""Return persisted health check samples captured by the admin console."""
try:
return await _uptime_from_database(window_hours)
except Exception:
return _unavailable_uptime(window_hours)
@router.get("/tickets", response_model=AdminTicketsResponse)
async def list_tickets(
principal: AdminPrincipal,
ticket_status: Annotated[TicketStatus | None, Query(alias="status")] = None,
window_days: Annotated[int, Query(ge=1, le=365)] = 30,
) -> AdminTicketsResponse:
"""Return user-submitted operational tickets without synthetic fallback rows."""
try:
return await _tickets_from_database(ticket_status=ticket_status, window_days=window_days)
except Exception:
return _unavailable_tickets()
@router.patch("/tickets/{ticket_id}", response_model=AdminSupportTicketResponse)
async def patch_ticket(
ticket_id: str,
body: AdminTicketPatch,
principal: AdminPrincipal,
) -> AdminSupportTicketResponse:
"""Update ticket triage state for administrators."""
try:
async with acquire(role="admin", user_id=principal.user_id) as conn:
row = await conn.fetchrow(
"""
UPDATE app.support_ticket SET
status = COALESCE($2, status),
priority = COALESCE($3, priority),
assigned_group = COALESCE($4, assigned_group),
resolution_note = COALESCE($5, resolution_note),
resolved_at = CASE
WHEN COALESCE($2, status) IN ('resolved', 'closed')
THEN COALESCE(resolved_at, now())
WHEN $2 IS NOT NULL
THEN NULL
ELSE resolved_at
END,
updated_at = now(),
last_activity_at = now()
WHERE id = $1::uuid
RETURNING
id,
reporter_id,
reporter_email,
reporter_name,
reporter_role,
category,
priority,
status,
subject,
body,
source_path,
assigned_group,
resolution_note,
created_at,
updated_at,
resolved_at
""",
ticket_id,
body.status,
body.priority,
body.assigned_group.strip() if body.assigned_group is not None else None,
body.resolution_note.strip() if body.resolution_note is not None else None,
)
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="ticket persistence unavailable",
) from exc
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="ticket not found")
return _ticket_from_row(row)
@router.get("/engine-config", response_model=AdminEngineConfigResponse)
async def get_engine_config(principal: AdminPrincipal) -> AdminEngineConfigResponse:
"""Return the current admin-managed engine settings."""
@ -728,10 +1242,14 @@ async def create_user(
principal: AdminPrincipal,
) -> AdminUserResponse:
"""Create or reactivate a managed user without requiring that user to log in first."""
if body.role == "admin" or body.admin_access:
_assert_super_admin(principal)
user = await upsert_managed_user(
email=_normalize_email(body.email),
display_name=body.display_name,
role=body.role,
admin_access=body.admin_access,
account_status=body.account_status,
affiliation=body.affiliation,
cohort_ids=body.cohort_ids,
reactivate=True,
@ -749,12 +1267,27 @@ async def patch_user(
principal: AdminPrincipal,
) -> AdminUserResponse:
"""Update a server-known user's role/profile for the current API process."""
current = await get_managed_user(user_id)
if current is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
if _requires_super_admin_for_privilege_change(
current_role=current.role,
next_role=body.role,
current_admin_access=current.admin_access,
next_admin_access=body.admin_access,
):
_assert_super_admin(principal)
_assert_super_admin_target_mutable(current.email, body.admin_access)
next_user = await update_managed_user(
user_id,
display_name=body.display_name,
role=body.role,
affiliation=body.affiliation,
cohort_ids=body.cohort_ids,
ManagedUserPatch(
display_name=body.display_name,
role=body.role,
admin_access=body.admin_access,
account_status=body.account_status,
affiliation=body.affiliation,
cohort_ids=body.cohort_ids,
),
)
if next_user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
@ -772,6 +1305,11 @@ async def delete_user(
"""Deactivate a managed user and revoke any active browser sessions."""
if user_id == principal.user_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="cannot deactivate current admin")
current = await get_managed_user(user_id)
if current is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
if is_super_admin_email(current.email):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="cannot deactivate super admin")
if not await deactivate_managed_user(user_id):
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
return AdminUserDeleteResponse(ok=True, user_id=user_id)

View file

@ -25,8 +25,14 @@ from fastapi import APIRouter, Cookie, HTTPException, Query, Request, Response,
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from ..auth_types import AccountStatus, RoleName
from ..auth_sessions import (
ManagedUser,
get_managed_user,
get_managed_user_by_email,
has_admin_access,
InactiveUserError,
is_super_admin_email,
SessionUser,
create_session,
record_user_consent,
@ -76,9 +82,17 @@ class MeResponse(BaseModel):
user_id: str
email: str
display_name: str
role: str
role: RoleName
admin_access: bool = False
super_admin: bool = False
account_status: AccountStatus = "approved"
approval_required: bool = False
cohort_ids: list[str]
consent_at: float | None = None
onboarding_completed_at: float | None = None
nickname: str = ""
self_introduction: str = ""
avatar_url: str = ""
class ConsentRequest(BaseModel):
@ -107,7 +121,7 @@ class AuthConfigResponse(BaseModel):
class DevLoginRequest(BaseModel):
email: str
role: Literal["learner", "teacher", "admin"] = "learner"
role: RoleName = "learner"
display_name: str | None = None
@ -205,8 +219,44 @@ def validate_google_identity_domain(
return normalized_email
async def validate_login_identity_email(
*,
email: str | None,
email_verified: bool,
hosted_domain: str | None = None,
) -> tuple[str, ManagedUser | None]:
"""Validate provider email, allowing exact admin-created managed accounts."""
async def managed_user_for_email(normalized_email: str) -> ManagedUser | None:
try:
return await get_managed_user_by_email(normalized_email)
except HTTPException:
return None
try:
normalized_email = validate_google_identity_domain(
email=email,
email_verified=email_verified,
hosted_domain=hosted_domain,
)
except HTTPException as exc:
normalized_email = _normalize_email(email)
if (
exc.status_code == status.HTTP_403_FORBIDDEN
and normalized_email
and _email_domain(normalized_email)
and email_verified
):
managed_user = await managed_user_for_email(normalized_email)
if managed_user is not None:
return normalized_email, managed_user
raise
return normalized_email, await managed_user_for_email(normalized_email)
def _role_for_email(email: str) -> Role:
normalized = _normalize_email(email)
if normalized in _normalize_email_set(settings.auth_super_admin_emails):
return Role.ADMIN
if normalized in _normalize_email_set(settings.auth_admin_emails):
return Role.ADMIN
if normalized in _normalize_email_set(settings.auth_teacher_emails):
@ -214,6 +264,22 @@ def _role_for_email(email: str) -> Role:
return Role.LEARNER
def _role_for_managed_user(managed_user: ManagedUser | None, fallback: Role) -> Role:
if managed_user is None:
return fallback
if managed_user.role == "admin":
return Role.ADMIN
if managed_user.role == "teacher":
return Role.TEACHER
return Role.LEARNER
def _cohort_ids_for_managed_user(managed_user: ManagedUser | None, fallback: list[str]) -> list[str]:
if managed_user is not None and managed_user.cohort_ids:
return list(managed_user.cohort_ids)
return fallback
def _role_for_saml_identity(identity: SamlIdentity) -> Role:
hinted = (identity.role_hint or "").strip().lower()
if hinted in {"admin", "administrator"}:
@ -573,14 +639,48 @@ def _delete_session_cookie(response: Response) -> None:
)
def _me_response(user: SessionUser | Principal) -> MeResponse:
async def _me_response(user: SessionUser | Principal) -> MeResponse:
managed = await get_managed_user(user.user_id)
onboarding_completed_at = getattr(user, "profile_completed_at", None)
if managed:
onboarding_completed_at = (
managed.profile_completed_at
if (
managed.profile_completed_at is not None
and managed.terms_agreed_at is not None
and managed.privacy_agreed_at is not None
and managed.nickname.strip()
and managed.self_introduction.strip()
)
else None
)
account_status = (
managed.account_status
if managed
else getattr(user, "account_status", "approved")
)
email = getattr(user, "email", "")
role = user.role.value if isinstance(user.role, Role) else user.role
stored_admin_access = managed.admin_access if managed else getattr(user, "admin_access", False)
return MeResponse(
user_id=user.user_id,
email=getattr(user, "email", ""),
display_name=getattr(user, "display_name", "") or getattr(user, "email", ""),
role=user.role.value if isinstance(user.role, Role) else user.role,
email=email,
display_name=(
(managed.display_name if managed else "")
or getattr(user, "display_name", "")
or email
),
role=role,
admin_access=has_admin_access(email, role, stored_admin_access),
super_admin=is_super_admin_email(email),
account_status=account_status,
approval_required=account_status != "approved",
cohort_ids=user.cohort_ids,
consent_at=getattr(user, "consent_at", None),
consent_at=(managed.consent_at if managed else getattr(user, "consent_at", None)),
onboarding_completed_at=onboarding_completed_at,
nickname=(managed.nickname if managed else ""),
self_introduction=(managed.self_introduction if managed else ""),
avatar_url=(managed.avatar_url if managed else ""),
)
@ -812,7 +912,7 @@ async def callback(
return _oauth_callback_error("issuer_mismatch", request)
try:
email = validate_google_identity_domain(
email, managed_user = await validate_login_identity_email(
email=claims.get("email"),
email_verified=claims.get("email_verified") in {True, "true", "True", "1", 1},
hosted_domain=claims.get("hd"),
@ -825,11 +925,14 @@ async def callback(
hosted_domain=_normalize_domain(str(claims.get("hd") or "")),
)
return _oauth_callback_error("domain_not_allowed", request)
role = _role_for_email(email)
role = _role_for_managed_user(managed_user, _role_for_email(email))
display_name = str(claims.get("name") or email)
cohort_ids = _configured_cohort_ids(
email=email,
hosted_domain=str(claims.get("hd") or ""),
cohort_ids = _cohort_ids_for_managed_user(
managed_user,
_configured_cohort_ids(
email=email,
hosted_domain=str(claims.get("hd") or ""),
),
)
external_id = _provider_external_id("google", str(claims.get("sub") or ""), email)
try:
@ -882,7 +985,7 @@ async def saml_acs(request: Request) -> RedirectResponse:
try:
identity = parse_fixture_response(encoded_response)
email = validate_google_identity_domain(
email, managed_user = await validate_login_identity_email(
email=identity.email,
email_verified=True,
hosted_domain=_email_domain(identity.email),
@ -890,8 +993,11 @@ async def saml_acs(request: Request) -> RedirectResponse:
except (HTTPException, ValueError):
return _frontend_login_redirect("saml_assertion_invalid", request)
role = _role_for_saml_identity(identity)
cohort_ids = _configured_cohort_ids(email=email, claim_hint=identity.cohort_hint)
role = _role_for_managed_user(managed_user, _role_for_saml_identity(identity))
cohort_ids = _cohort_ids_for_managed_user(
managed_user,
_configured_cohort_ids(email=email, claim_hint=identity.cohort_hint),
)
external_id = _provider_external_id("saml", identity.subject, email)
try:
sid, _ = await create_session(
@ -919,7 +1025,7 @@ async def dev_login(request: Request, body: DevLoginRequest, response: Response)
if not _dev_login_available(request):
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="dev login is disabled")
email = validate_google_identity_domain(
email, managed_user = await validate_login_identity_email(
email=str(body.email),
email_verified=True,
hosted_domain=_email_domain(str(body.email)),
@ -928,14 +1034,17 @@ async def dev_login(request: Request, body: DevLoginRequest, response: Response)
sid, user = await create_session(
email=email,
display_name=body.display_name or email,
role=body.role,
cohort_ids=_configured_cohort_ids(email=email),
role=_role_for_managed_user(managed_user, Role(body.role)).value,
cohort_ids=_cohort_ids_for_managed_user(
managed_user,
_configured_cohort_ids(email=email),
),
external_id=_provider_external_id("dev", email, email),
)
except InactiveUserError as exc:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="user is inactive") from exc
_set_session_cookie(response, sid)
return _me_response(user)
return await _me_response(user)
@router.post("/logout")
@ -956,6 +1065,8 @@ async def accept_consent(
principal: CurrentPrincipal,
) -> ConsentResponse:
"""Record the current learner's practice-session consent receipt."""
if principal.role != Role.LEARNER and principal.super_admin:
principal = principal.with_role(Role.LEARNER)
if principal.role != Role.LEARNER:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only")
if not body.accepted:
@ -970,6 +1081,8 @@ async def accept_consent(
@router.delete("/consent", response_model=ConsentResponse)
async def withdraw_consent(principal: CurrentPrincipal) -> ConsentResponse:
"""Withdraw practice-session consent until the learner accepts again."""
if principal.role != Role.LEARNER and principal.super_admin:
principal = principal.with_role(Role.LEARNER)
if principal.role != Role.LEARNER:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only")
changed = await withdraw_user_consent(principal.user_id)
@ -982,4 +1095,4 @@ async def withdraw_consent(principal: CurrentPrincipal) -> ConsentResponse:
@router.get("/me", response_model=MeResponse)
async def me(principal: CurrentPrincipal) -> MeResponse:
"""Return the current authenticated user. Unauthenticated requests are 401."""
return _me_response(principal)
return await _me_response(principal)

View file

@ -12,9 +12,10 @@ from fastapi import Response
from starlette.requests import Request
from unittest.mock import AsyncMock, patch
from . import auth_sessions
from . import auth_sessions, deps
from .config import Settings, settings
from .deps import Principal, Role
from .routes import admin as admin_routes
from .routes import auth as auth_routes
from .saml import inflate_redirect_request
@ -31,12 +32,15 @@ def patched_settings(**values: Any):
setattr(settings, key, value)
def _request(headers: list[tuple[bytes, bytes]] | None = None) -> Request:
def _request(
headers: list[tuple[bytes, bytes]] | None = None,
path: str = "/auth/login",
) -> Request:
return Request(
{
"type": "http",
"method": "GET",
"path": "/auth/login",
"path": path,
"headers": headers or [(b"host", b"localhost:8000")],
}
)
@ -198,6 +202,228 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(teacher_error.exception.status_code, 403)
self.assertEqual(learner_error.exception.status_code, 400)
async def test_new_provider_account_waits_for_admin_approval(self) -> None:
with (
patched_settings(
environment="dev",
auth_new_user_default_status="pending",
auth_super_admin_emails=["yunchan@twentyoz.kr"],
auth_admin_emails=[],
auth_teacher_emails=[],
auth_approved_emails=[],
),
patch.object(auth_sessions, "get_pool", side_effect=RuntimeError("no db")),
):
sid, user = await auth_sessions.create_session(
email="pending@hs.ac.kr",
display_name="Pending Learner",
role="learner",
external_id="google:pending-provider-sub",
)
self.assertEqual(user.account_status, "pending")
me = await auth_routes._me_response(user)
self.assertEqual(me.account_status, "pending")
self.assertTrue(me.approval_required)
principal = await deps.get_current_principal(
_request(path="/auth/me"),
dev_session_cookie=sid,
)
self.assertEqual(principal.account_status, "pending")
with self.assertRaises(auth_routes.HTTPException) as blocked:
await deps.get_current_principal(
_request(path="/personas"),
dev_session_cookie=sid,
)
self.assertEqual(blocked.exception.status_code, 403)
self.assertEqual(blocked.exception.detail, "account_pending")
async def test_dev_login_account_is_auto_approved_for_local_e2e(self) -> None:
with (
patched_settings(
environment="dev",
auth_new_user_default_status="pending",
auth_super_admin_emails=["yunchan@twentyoz.kr"],
auth_admin_emails=[],
auth_teacher_emails=[],
auth_approved_emails=[],
),
patch.object(auth_sessions, "get_pool", side_effect=RuntimeError("no db")),
):
sid, user = await auth_sessions.create_session(
email="dev-learner@hs.ac.kr",
display_name="Dev Learner",
role="learner",
external_id="dev:dev-learner@hs.ac.kr",
)
self.assertEqual(user.account_status, "approved")
principal = await deps.get_current_principal(
_request(path="/personas"),
dev_session_cookie=sid,
)
self.assertEqual(principal.account_status, "approved")
async def test_admin_created_external_domain_user_can_login(self) -> None:
with (
patched_settings(
environment="dev",
auth_dev_login_enabled=True,
auth_allowed_email_domains=["hs.ac.kr"],
auth_new_user_default_status="pending",
auth_super_admin_emails=["yunchan@twentyoz.kr"],
auth_admin_emails=[],
auth_teacher_emails=[],
auth_approved_emails=[],
),
patch.object(auth_sessions, "get_pool", side_effect=RuntimeError("no db")),
):
super_admin = Principal(
user_id="00000000-0000-0000-0000-000000000604",
role=Role.ADMIN,
email="yunchan@twentyoz.kr",
display_name="Yun Chan",
admin_access=True,
super_admin=True,
)
created = await admin_routes.create_user(
admin_routes.AdminUserCreate(
email="forced.gmail@gmail.com",
display_name="Forced Gmail",
role="teacher",
account_status="approved",
cohort_ids=["manual-cohort"],
),
super_admin,
)
me = await auth_routes.dev_login(
_request(path="/auth/dev-login"),
auth_routes.DevLoginRequest(
email="forced.gmail@gmail.com",
role="learner",
display_name="Provider Name",
),
Response(),
)
self.assertEqual(me.user_id, created.user_id)
self.assertEqual(me.email, "forced.gmail@gmail.com")
self.assertEqual(me.role, "teacher")
self.assertEqual(me.account_status, "approved")
self.assertEqual(me.cohort_ids, ["manual-cohort"])
with self.assertRaises(auth_routes.HTTPException) as denied:
await auth_routes.dev_login(
_request(path="/auth/dev-login"),
auth_routes.DevLoginRequest(
email="unmanaged@gmail.com",
role="learner",
display_name="Unmanaged Gmail",
),
Response(),
)
self.assertEqual(denied.exception.status_code, 403)
async def test_super_admin_email_is_admin_and_auto_approved(self) -> None:
with (
patched_settings(
environment="dev",
auth_new_user_default_status="pending",
auth_super_admin_emails=["yunchan@twentyoz.kr", "hoonjungkoo@hs.ac.kr"],
auth_admin_emails=[],
auth_teacher_emails=[],
auth_approved_emails=[],
),
patch.object(auth_sessions, "get_pool", side_effect=RuntimeError("no db")),
):
role = auth_routes._role_for_email("yunchan@twentyoz.kr")
_, user = await auth_sessions.create_session(
email="yunchan@twentyoz.kr",
display_name="Yun Chan",
role=role.value,
external_id="google:yunchan",
)
self.assertEqual(role, Role.ADMIN)
self.assertEqual(user.role, "admin")
self.assertTrue(user.admin_access)
self.assertEqual(user.account_status, "approved")
hoonjung_role = auth_routes._role_for_email("hoonjungkoo@hs.ac.kr")
self.assertEqual(hoonjung_role, Role.ADMIN)
async def test_super_admin_can_enter_teacher_and_learner_role_guards(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000601",
role=Role.LEARNER,
email="hoonjungkoo@hs.ac.kr",
display_name="Super Admin",
admin_access=True,
super_admin=True,
)
teacher_checker = deps.require_role(Role.TEACHER, Role.ADMIN)
teacher_view = await teacher_checker(principal)
self.assertEqual(teacher_view.role, Role.ADMIN)
self.assertTrue(principal.can_access_role(Role.LEARNER))
self.assertTrue(principal.can_access_role(Role.TEACHER))
self.assertTrue(principal.can_access_role(Role.ADMIN))
async def test_only_super_admin_can_grant_admin_access(self) -> None:
with (
patched_settings(
environment="dev",
auth_super_admin_emails=["yunchan@twentyoz.kr"],
auth_admin_emails=[],
auth_teacher_emails=[],
auth_approved_emails=[],
),
patch.object(auth_sessions, "get_pool", side_effect=RuntimeError("no db")),
):
target = await auth_sessions.upsert_managed_user(
email="admin-grant-target@hs.ac.kr",
display_name="Grant Target",
role="learner",
external_id="dev:admin-grant-target@hs.ac.kr",
)
operator = Principal(
user_id="00000000-0000-0000-0000-000000000602",
role=Role.LEARNER,
email="operator@hs.ac.kr",
display_name="Operator",
admin_access=True,
super_admin=False,
)
with self.assertRaises(auth_routes.HTTPException) as denied:
await admin_routes.patch_user(
target.user_id,
admin_routes.AdminUserPatch(admin_access=True),
operator,
)
self.assertEqual(denied.exception.status_code, 403)
super_admin = Principal(
user_id="00000000-0000-0000-0000-000000000603",
role=Role.ADMIN,
email="yunchan@twentyoz.kr",
display_name="Yun Chan",
admin_access=True,
super_admin=True,
)
updated = await admin_routes.patch_user(
target.user_id,
admin_routes.AdminUserPatch(admin_access=True),
super_admin,
)
self.assertTrue(updated.admin_access)
self.assertEqual(updated.role, "learner")
async def test_auth_config_allows_dev_login_from_configured_tailnet_forwarded_host(self) -> None:
request = _request(
[

File diff suppressed because it is too large Load diff

View file

@ -44,7 +44,7 @@ function oauthMessage(reason: string | null): string | null {
return OAUTH_IDENTITY_FAILED_MESSAGE;
}
if (reason === "domain_not_allowed") {
return "승인된 이메일 도메인의 Google 계정만 사용할 수 있습니다.";
return "승인된 이메일 도메인 또는 관리자 등록 계정만 사용할 수 있습니다.";
}
if (reason === "inactive_user") {
return "비활성화된 계정입니다. 관리자에게 계정 상태 확인을 요청하세요.";
@ -165,7 +165,14 @@ export default function Login() {
setLoginErrorReason(null);
try {
const signedIn = await login(role);
navigate(roleHomePath(signedIn.role), { replace: true });
navigate(
signedIn.accountStatus !== "approved"
? "/pending"
: signedIn.onboardingCompletedAt == null
? "/onboarding"
: roleHomePath(signedIn.role),
{ replace: true },
);
} catch (err) {
setLoginError(err instanceof Error ? err.message : "로그인에 실패했습니다.");
} finally {
@ -199,7 +206,7 @@ export default function Login() {
<span className="d" aria-hidden="true" />
</span>
<h1> , .</h1>
<h1><span className="lg-highlight"> </span>, <br/> .</h1>
<p>
Vignette는 , , .
.
@ -340,20 +347,19 @@ const LOGIN_CSS = `
min-height:100dvh;
position:relative;
display:grid;
grid-template-columns:minmax(0,1fr) minmax(360px,480px);
background:var(--bg-app);
color:var(--text-strong);
overflow:hidden;
grid-template-columns:minmax(0,1fr) minmax(360px,500px);
background:
linear-gradient(90deg,rgba(7,16,14,.84) 0%,rgba(10,21,19,.72) 48%,rgba(10,21,19,.55) 74%,rgba(10,21,19,.62) 100%),
var(--asset-login-room) center / cover no-repeat;
color:#edf4f2;
overflow-x:hidden;
overflow-y:auto;
}
.lg-root::before{
content:"";
position:absolute;
inset:auto -12vw -22vw 38vw;
height:42vw;
min-height:360px;
background:var(--asset-warm-elements) center / cover no-repeat;
opacity:.08;
filter:saturate(.82);
inset:0;
background:linear-gradient(180deg,rgba(3,9,8,.18),rgba(3,9,8,.42));
pointer-events:none;
}
.lg-brand,
@ -363,29 +369,19 @@ const LOGIN_CSS = `
}
.lg-brand{
min-width:0;
width:100%;
position:relative;
display:flex;
flex-direction:column;
justify-content:space-between;
gap:var(--sp-7);
padding:var(--sp-7);
background:
linear-gradient(115deg,rgba(14,22,20,.86),rgba(30,39,36,.7) 48%,rgba(30,39,36,.48)),
var(--asset-login-room) center / cover no-repeat;
background:transparent;
color:#edf4f2;
overflow:hidden;
}
.lg-brand::after{
content:"";
position:absolute;
left:var(--sp-7);
bottom:var(--sp-7);
width:min(360px,42vw);
height:120px;
border:1px solid rgba(255,255,255,.12);
border-radius:var(--radius-lg);
background:rgba(251,250,248,.06);
pointer-events:none;
display:none;
}
.lg-wordmark{
position:relative;
@ -412,11 +408,22 @@ const LOGIN_CSS = `
font-family:var(--font-num);
font-size:12px;
font-weight:700;
letter-spacing:.08em;
letter-spacing:0;
text-transform:uppercase;
color:var(--accent-bright);
}
.lg-kicker .d{width:6px;height:6px;border-radius:50%;background:currentColor;}
.lg-highlight{
display:inline-block;
position:relative;
z-index:0;
padding:0 4px;
background:linear-gradient(90deg,#59b5a6,#8ee4d6);
-webkit-background-clip:text;
background-clip:text;
-webkit-text-fill-color:transparent;
color:transparent;
}
.lg-copy h1{
margin:var(--sp-4) 0 0;
max-width:640px;
@ -437,6 +444,14 @@ const LOGIN_CSS = `
align-items:center;
flex-wrap:wrap;
gap:10px;
width:max-content;
max-width:min(430px,100%);
padding:14px 16px;
border:1px solid rgba(255,255,255,.14);
border-radius:var(--radius-lg);
background:rgba(237,244,242,.08);
backdrop-filter:blur(18px) saturate(1.08);
-webkit-backdrop-filter:blur(18px) saturate(1.08);
color:rgba(237,244,242,.66);
font-size:13px;
}
@ -459,27 +474,30 @@ const LOGIN_CSS = `
align-items:center;
justify-content:center;
padding:var(--sp-6);
background:
linear-gradient(180deg,rgba(251,250,248,.9),rgba(244,242,238,.76));
background:transparent;
}
.lg-panel{
width:100%;
max-width:400px;
background:var(--bg-surface);
border:1px solid var(--border-subtle);
max-width:430px;
background:rgba(11,25,22,.64);
border:1px solid rgba(255,255,255,.16);
border-radius:var(--radius-lg);
box-shadow:var(--shadow-sm);
box-shadow:0 24px 70px rgba(0,0,0,.34), inset 0 1px 0 rgba(255,255,255,.08);
backdrop-filter:blur(24px) saturate(1.14);
-webkit-backdrop-filter:blur(24px) saturate(1.14);
padding:var(--sp-6);
color:#edf4f2;
}
.lg-panel h2{
margin:var(--sp-3) 0 0;
font-size:28px;
line-height:1.25;
letter-spacing:0;
color:#f7fbf9;
}
.lg-lead{
margin:10px 0 0;
color:var(--text-body);
color:rgba(237,244,242,.76);
font-size:14px;
line-height:1.65;
}
@ -499,27 +517,35 @@ const LOGIN_CSS = `
text-align:left;
cursor:pointer;
}
.lg-obtn.primary{background:var(--accent);border:1px solid var(--accent);color:var(--text-on-accent);}
.lg-obtn.primary:hover{background:var(--accent-deep);border-color:var(--accent-deep);}
.lg-obtn.secondary{background:var(--bg-surface);border:1px solid var(--border-strong);color:var(--text-strong);}
.lg-obtn.secondary:hover{border-color:var(--accent);}
.lg-obtn.primary{
background:rgba(89,181,166,.2);
border:1px solid rgba(114,211,197,.44);
color:#f7fbf9;
}
.lg-obtn.primary:hover{background:rgba(89,181,166,.28);border-color:rgba(114,211,197,.62);}
.lg-obtn.secondary{
background:rgba(255,255,255,.08);
border:1px solid rgba(255,255,255,.13);
color:#edf4f2;
}
.lg-obtn.secondary:hover{border-color:rgba(114,211,197,.42);background:rgba(255,255,255,.11);}
.lg-obtn:disabled{
cursor:not-allowed;
opacity:1;
background:var(--bg-surface-2);
border-color:var(--border-subtle);
color:var(--text-muted);
background:rgba(255,255,255,.07);
border-color:rgba(255,255,255,.1);
color:rgba(237,244,242,.58);
}
.lg-obtn:disabled:hover{
background:var(--bg-surface-2);
border-color:var(--border-subtle);
background:rgba(255,255,255,.07);
border-color:rgba(255,255,255,.1);
}
.lg-obtn:disabled .ic{
background:var(--bg-surface);
color:var(--text-muted);
background:rgba(255,255,255,.08);
color:rgba(237,244,242,.58);
}
.lg-obtn:disabled .sub{
color:var(--text-muted);
color:rgba(237,244,242,.45);
}
.lg-obtn .ic{
width:36px;
@ -529,9 +555,9 @@ const LOGIN_CSS = `
border-radius:8px;
background:rgba(255,255,255,.16);
}
.lg-obtn.secondary .ic{background:var(--bg-surface-2);}
.lg-obtn.secondary .ic{background:rgba(7,17,15,.34);}
.lg-obtn .txt{min-width:0;display:flex;flex-direction:column;gap:1px;}
.lg-obtn .sub{font-size:12px;font-weight:550;color:var(--text-muted);}
.lg-obtn .sub{font-size:12px;font-weight:550;color:rgba(237,244,242,.58);}
.lg-obtn.primary .sub{color:rgba(251,250,248,.74);}
.lg-config{
display:flex;
@ -540,26 +566,27 @@ const LOGIN_CSS = `
margin-top:var(--sp-3);
padding:10px 12px;
border-radius:var(--radius);
background:var(--warn-tint);
color:var(--warn-text);
background:rgba(154,94,20,.28);
border:1px solid rgba(236,180,91,.16);
color:#f2b75f;
font-size:12.5px;
line-height:1.5;
}
.lg-dev{
margin-top:var(--sp-6);
padding-top:var(--sp-5);
border-top:1px solid var(--hair);
border-top:1px solid rgba(255,255,255,.1);
}
.lg-devhead{
display:flex;
align-items:baseline;
justify-content:space-between;
gap:var(--sp-3);
color:var(--text-strong);
color:#edf4f2;
font-size:13px;
font-weight:700;
}
.lg-devhead small{color:var(--text-muted);font-weight:600;}
.lg-devhead small{color:rgba(237,244,242,.58);font-weight:600;}
.lg-rolepick{display:grid;grid-template-columns:1fr;gap:var(--sp-2);margin-top:var(--sp-3);}
.lg-roleopt{
min-height:50px;
@ -568,28 +595,28 @@ const LOGIN_CSS = `
align-items:center;
gap:10px;
padding:9px 11px;
border:1px solid var(--border-subtle);
border:1px solid rgba(255,255,255,.11);
border-radius:var(--radius);
background:var(--bg-surface);
color:var(--text-strong);
background:rgba(255,255,255,.045);
color:#edf4f2;
text-align:left;
cursor:pointer;
}
.lg-roleopt.is-sel{border-color:var(--accent);background:var(--accent-tint);}
.lg-roleopt.is-sel{border-color:rgba(114,211,197,.58);background:rgba(89,181,166,.15);}
.lg-roleopt .d{width:8px;height:8px;border-radius:50%;}
.lg-roleopt .d.learner{background:var(--accent-bright);}
.lg-roleopt .d.teacher{background:#5478c4;}
.lg-roleopt .d.admin{background:#7d818e;}
.lg-roleopt b{display:block;font-size:13px;}
.lg-roleopt small{display:block;margin-top:1px;color:var(--text-muted);font-size:12px;}
.lg-roleopt small{display:block;margin-top:1px;color:rgba(237,244,242,.58);font-size:12px;}
.lg-devbtn{
width:100%;
min-height:46px;
margin-top:var(--sp-3);
border:1px solid var(--accent);
border:1px solid rgba(114,211,197,.58);
border-radius:var(--radius);
background:var(--accent-tint);
color:var(--accent-deep);
background:rgba(89,181,166,.15);
color:#8fe7d9;
font-family:var(--font-sans);
font-size:14px;
font-weight:750;
@ -599,7 +626,8 @@ const LOGIN_CSS = `
.lg-error{
margin:var(--sp-3) 0 0;
color:var(--crit-text);
background:var(--crit-tint);
background:rgba(122,38,38,.28);
border:1px solid rgba(255,145,145,.18);
border-radius:var(--radius);
padding:10px 12px;
font-size:13px;
@ -608,14 +636,14 @@ const LOGIN_CSS = `
.lg-error small{
display:block;
margin-top:4px;
color:var(--text-muted);
color:rgba(237,244,242,.56);
font-family:var(--font-num);
font-size:11.5px;
overflow-wrap:anywhere;
}
.lg-note{
margin:var(--sp-5) 0 0;
color:var(--text-muted);
color:rgba(237,244,242,.52);
font-size:12.5px;
line-height:1.6;
}