diff --git a/apps/api/app/auth_sessions.py b/apps/api/app/auth_sessions.py index 0a61f96..277b589 100644 --- a/apps/api/app/auth_sessions.py +++ b/apps/api/app/auth_sessions.py @@ -13,9 +13,9 @@ import secrets import time import uuid from datetime import datetime, timezone -from dataclasses import dataclass -from typing import Literal +from dataclasses import dataclass, replace +from .auth_types import AccountStatus, RoleName from .config import settings from .db import get_pool from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed @@ -26,9 +26,13 @@ class SessionUser: user_id: str email: str display_name: str - role: str + role: RoleName + admin_access: bool + super_admin: bool + account_status: AccountStatus cohort_ids: list[str] consent_at: float | None + profile_completed_at: float | None expires_at: float @@ -37,14 +41,110 @@ class ManagedUser: user_id: str email: str display_name: str - role: str + role: RoleName + admin_access: bool + account_status: AccountStatus cohort_ids: list[str] affiliation: str + legal_name: str + department: str + grade_level: str + phone: str + contact_address: str + nickname: str + self_introduction: str + avatar_url: str consent_at: float | None + profile_completed_at: float | None + terms_agreed_at: float | None + privacy_agreed_at: float | None + terms_version: str + privacy_version: str created_at: float last_seen_at: float +@dataclass(slots=True) +class ManagedUserMemoryInput: + email: str + display_name: str + role: RoleName + admin_access: bool | None = None + account_status: AccountStatus | None = None + cohort_ids: list[str] | None = None + user_id: str | None = None + affiliation: str | None = None + legal_name: str | None = None + department: str | None = None + grade_level: str | None = None + phone: str | None = None + contact_address: str | None = None + nickname: str | None = None + self_introduction: str | None = None + avatar_url: str | None = None + consent_at: float | None = None + profile_completed_at: float | None = None + terms_agreed_at: float | None = None + privacy_agreed_at: float | None = None + terms_version: str | None = None + privacy_version: str | None = None + reactivate: bool = False + + @classmethod + def from_user( + cls, + user: ManagedUser, + *, + reactivate: bool = False, + ) -> "ManagedUserMemoryInput": + return cls( + email=user.email, + display_name=user.display_name, + role=user.role, + admin_access=user.admin_access, + account_status=user.account_status, + cohort_ids=list(user.cohort_ids), + user_id=user.user_id, + affiliation=user.affiliation, + legal_name=user.legal_name, + department=user.department, + grade_level=user.grade_level, + phone=user.phone, + contact_address=user.contact_address, + nickname=user.nickname, + self_introduction=user.self_introduction, + avatar_url=user.avatar_url, + consent_at=user.consent_at, + profile_completed_at=user.profile_completed_at, + terms_agreed_at=user.terms_agreed_at, + privacy_agreed_at=user.privacy_agreed_at, + terms_version=user.terms_version, + privacy_version=user.privacy_version, + reactivate=reactivate, + ) + + +@dataclass(slots=True) +class ManagedUserPatch: + display_name: str | None = None + role: RoleName | None = None + admin_access: bool | None = None + account_status: AccountStatus | None = None + affiliation: str | None = None + legal_name: str | None = None + department: str | None = None + grade_level: str | None = None + phone: str | None = None + contact_address: str | None = None + nickname: str | None = None + self_introduction: str | None = None + avatar_url: str | None = None + cohort_ids: list[str] | None = None + complete_onboarding: bool = False + terms_version: str | None = None + privacy_version: str | None = None + + class InactiveUserError(Exception): """Raised when an inactive managed user attempts to create a login session.""" @@ -55,9 +155,9 @@ _email_index: dict[str, str] = {} _inactive_emails: set[str] = set() DEFAULT_AFFILIATION = settings.default_affiliation.strip() -RoleName = Literal["learner", "teacher", "admin"] DB_ROLE_BY_APP = {"learner": "learner", "teacher": "instructor", "admin": "admin"} APP_ROLE_BY_DB = {"learner": "learner", "instructor": "teacher", "admin": "admin"} +VALID_ACCOUNT_STATUSES: set[str] = {"pending", "approved", "suspended"} def _sid_hash(raw_sid: str) -> str: @@ -93,6 +193,65 @@ def _app_role(role: str) -> str: return APP_ROLE_BY_DB.get(role, role) +def _account_status(value: str | None) -> AccountStatus: + normalized = (value or "").strip().lower() + if normalized in VALID_ACCOUNT_STATUSES: + return normalized # type: ignore[return-value] + return "approved" + + +def _normalize_email_set(values: list[str]) -> set[str]: + return {email for value in values if (email := _normalize_email(value))} + + +def _auto_approved_email_set() -> set[str]: + return ( + _normalize_email_set(settings.auth_super_admin_emails) + | _normalize_email_set(settings.auth_admin_emails) + | _normalize_email_set(settings.auth_teacher_emails) + | _normalize_email_set(settings.auth_approved_emails) + ) + + +def is_super_admin_email(email: str) -> bool: + """설정 기반 신뢰 루트인 슈퍼 관리자 계정인지 판정한다.""" + normalized_email = _normalize_email(email) + return normalized_email in _normalize_email_set(settings.auth_super_admin_emails) + + +def has_admin_access( + email: str, + role: str | None = None, + stored_admin_access: bool = False, +) -> bool: + """기본 역할을 유지한 채 관리자 화면에 들어갈 수 있는 계정인지 판정한다.""" + normalized_email = _normalize_email(email) + return ( + stored_admin_access + or role == "admin" + or is_super_admin_email(normalized_email) + or normalized_email in _normalize_email_set(settings.auth_admin_emails) + ) + + +def _is_dev_login_external_id(external_id: str | None) -> bool: + return (external_id or "").strip().lower().startswith("dev:") + + +def _initial_account_status( + *, + email: str, + external_id: str | None, + reactivate: bool, +) -> AccountStatus: + normalized_email = _normalize_email(email) + if reactivate or normalized_email in _auto_approved_email_set(): + return "approved" + if settings.environment == "dev" and _is_dev_login_external_id(external_id): + return "approved" + return _account_status(settings.auth_new_user_default_status) + + def _ts(value: datetime | None) -> float: return (value or datetime.now(timezone.utc)).timestamp() @@ -124,10 +283,37 @@ async def _runtime_tables_ready(conn) -> bool: SELECT 1 FROM information_schema.columns WHERE table_schema = 'app' AND table_name = 'app_user' - AND column_name IN ('affiliation', 'consent_at', 'last_seen_at', 'updated_at') + AND column_name IN ( + 'affiliation', + 'legal_name', + 'department', + 'grade_level', + 'phone', + 'contact_address', + 'nickname', + 'self_introduction', + 'avatar_url', + 'consent_at', + 'profile_completed_at', + 'terms_agreed_at', + 'privacy_agreed_at', + 'terms_version', + 'privacy_version', + 'last_seen_at', + 'account_status', + 'admin_access', + 'updated_at' + ) GROUP BY table_schema, table_name - HAVING count(*) = 4 + HAVING count(*) = 19 ) AS has_user_columns, + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'app' + AND table_name = 'persona_card' + AND column_name = 'triggers' + ) AS has_persona_triggers, + to_regclass('app.persona_voice_map') IS NOT NULL AS has_persona_voice_map, to_regclass('app.auth_session') IS NOT NULL AS has_auth_session, to_regclass('app.user_preferences') IS NOT NULL AS has_preferences, to_regclass('app.admin_engine_config') IS NOT NULL AS has_engine_config, @@ -156,6 +342,29 @@ async def _runtime_tables_ready(conn) -> bool: FROM app.stage_def WHERE stage_code IN ('라포','탐색','개입','정리') ) AS has_stage_defs, + to_regclass('app.admin_health_event') IS NOT NULL AS has_admin_health_event, + to_regclass('app.support_ticket') IS NOT NULL AS has_support_ticket, + EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'app' + AND tablename = 'admin_health_event' + AND policyname IN ('p_admin_health_event_select','p_admin_health_event_insert') + GROUP BY schemaname, tablename + HAVING count(*) = 2 + ) AS has_admin_health_event_policies, + EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'app' + AND tablename = 'support_ticket' + AND policyname IN ( + 'p_support_ticket_select', + 'p_support_ticket_insert', + 'p_support_ticket_update', + 'p_support_ticket_delete' + ) + GROUP BY schemaname, tablename + HAVING count(*) = 4 + ) AS has_support_ticket_policies, EXISTS ( SELECT 1 FROM pg_policies WHERE schemaname = 'app' @@ -189,12 +398,18 @@ async def _runtime_tables_ready(conn) -> bool: return bool( row and row["has_user_columns"] + and row["has_persona_triggers"] + and row["has_persona_voice_map"] and row["has_auth_session"] and row["has_preferences"] and row["has_engine_config"] and row["has_session_columns"] and row["has_state_columns"] and row["has_stage_defs"] + and row["has_admin_health_event"] + and row["has_support_ticket"] + and row["has_admin_health_event_policies"] + and row["has_support_ticket_policies"] and row["has_session_write_policies"] and row["removed_old_session_policy"] and row["has_turn_write_policies"] @@ -206,10 +421,34 @@ async def ensure_runtime_tables() -> None: """Ensure DB-backed auth/user runtime tables exist when a pool is available.""" pool = get_pool() async with pool.acquire() as conn: + if await _runtime_tables_ready(conn): + return + if settings.environment != "dev": + raise RuntimeError( + "runtime DB schema is incomplete; run owner migration/init and " + "scripts/check-deploy-preflight.py before starting the API" + ) await conn.execute( """ DO $$ BEGIN + IF to_regclass('app.persona_card') IS NOT NULL THEN + ALTER TABLE app.persona_card + ADD COLUMN IF NOT EXISTS triggers JSONB NOT NULL DEFAULT '{}'::jsonb; + + CREATE TABLE IF NOT EXISTS app.persona_voice_map ( + persona_id UUID NOT NULL, + version INT NOT NULL, + voice_id TEXT NOT NULL, + provider TEXT NOT NULL CHECK (provider IN ('openai','higgs','melotts')), + base_params JSONB NOT NULL, + prosody_map JSONB NOT NULL, + PRIMARY KEY (persona_id, version), + FOREIGN KEY (persona_id, version) + REFERENCES app.persona_card(persona_id, version) ON DELETE CASCADE + ); + END IF; + IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'app' @@ -227,11 +466,44 @@ async def ensure_runtime_tables() -> None: """ ALTER TABLE app.app_user ADD COLUMN IF NOT EXISTS affiliation TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS legal_name TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS department TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS grade_level TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS phone TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS contact_address TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS nickname TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS self_introduction TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS avatar_url TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS consent_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS profile_completed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS terms_agreed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS privacy_agreed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS terms_version TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS privacy_version TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ADD COLUMN IF NOT EXISTS account_status TEXT NOT NULL DEFAULT 'approved', + ADD COLUMN IF NOT EXISTS admin_access BOOLEAN NOT NULL DEFAULT false, ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now() """ ) + await conn.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'app_user_account_status_check' + AND conrelid = 'app.app_user'::regclass + ) THEN + ALTER TABLE app.app_user + ADD CONSTRAINT app_user_account_status_check + CHECK (account_status IN ('pending','approved','suspended')); + END IF; + END $$; + CREATE INDEX IF NOT EXISTS idx_app_user_account_status + ON app.app_user(account_status, last_seen_at DESC); + """ + ) await conn.execute( """ CREATE TABLE IF NOT EXISTS app.auth_session ( @@ -278,6 +550,114 @@ async def ensure_runtime_tables() -> None: ) """ ) + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS app.admin_health_event ( + id BIGSERIAL PRIMARY KEY, + observed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + overall_status TEXT NOT NULL CHECK (overall_status IN ('ok','degraded','down')), + environment TEXT NOT NULL, + engine_mode TEXT NOT NULL, + service_key TEXT NOT NULL, + service_name TEXT NOT NULL, + service_status TEXT NOT NULL CHECK (service_status IN ('ok','degraded','down')), + detail TEXT NOT NULL DEFAULT '', + metric TEXT NOT NULL DEFAULT '', + load REAL NOT NULL DEFAULT 0.0 CHECK (load >= 0.0 AND load <= 1.0), + captured_by UUID REFERENCES app.app_user(user_id) + ) + """ + ) + await conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_admin_health_event_observed + ON app.admin_health_event(observed_at DESC, service_key) + """ + ) + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS app.support_ticket ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + reporter_id UUID REFERENCES app.app_user(user_id) ON DELETE SET NULL, + reporter_email TEXT NOT NULL, + reporter_name TEXT NOT NULL, + reporter_role TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'other' CHECK ( + category IN ( + 'account_access', + 'session_review', + 'voice_browser', + 'content_scenario', + 'safety', + 'other' + ) + ), + priority TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('low','normal','high','urgent')), + status TEXT NOT NULL DEFAULT 'open' CHECK ( + status IN ('open','triaged','in_progress','resolved','closed') + ), + subject TEXT NOT NULL, + body TEXT NOT NULL, + source_path TEXT NOT NULL DEFAULT '', + assigned_group TEXT NOT NULL DEFAULT '', + resolution_note TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ, + last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """ + ) + await conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_support_ticket_status_priority + ON app.support_ticket(status, priority, updated_at DESC) + """ + ) + await conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_support_ticket_reporter + ON app.support_ticket(reporter_id, created_at DESC) + """ + ) + await conn.execute( + """ + ALTER TABLE app.admin_health_event ENABLE ROW LEVEL SECURITY; + DROP POLICY IF EXISTS p_admin_health_event_select ON app.admin_health_event; + DROP POLICY IF EXISTS p_admin_health_event_insert ON app.admin_health_event; + CREATE POLICY p_admin_health_event_select + ON app.admin_health_event FOR SELECT + USING (app.current_role_name() = 'admin'); + CREATE POLICY p_admin_health_event_insert + ON app.admin_health_event FOR INSERT + WITH CHECK (app.current_role_name() = 'admin'); + + ALTER TABLE app.support_ticket ENABLE ROW LEVEL SECURITY; + DROP POLICY IF EXISTS p_support_ticket_select ON app.support_ticket; + DROP POLICY IF EXISTS p_support_ticket_insert ON app.support_ticket; + DROP POLICY IF EXISTS p_support_ticket_update ON app.support_ticket; + DROP POLICY IF EXISTS p_support_ticket_delete ON app.support_ticket; + CREATE POLICY p_support_ticket_select + ON app.support_ticket FOR SELECT + USING ( + app.current_role_name() = 'admin' + OR reporter_id = app.current_uid() + ); + CREATE POLICY p_support_ticket_insert + ON app.support_ticket FOR INSERT + WITH CHECK ( + app.current_role_name() = 'admin' + OR reporter_id = app.current_uid() + ); + CREATE POLICY p_support_ticket_update + ON app.support_ticket FOR UPDATE + USING (app.current_role_name() = 'admin') + WITH CHECK (app.current_role_name() = 'admin'); + CREATE POLICY p_support_ticket_delete + ON app.support_ticket FOR DELETE + USING (app.current_role_name() = 'admin'); + """ + ) await conn.execute( """ ALTER TABLE app.sessions @@ -402,45 +782,109 @@ def _managed_user_from_row(row) -> ManagedUser: email=row["email"], display_name=row["display_name"] or row["email"], role=_app_role(row["role"]), + admin_access=bool(_row_value(row, "admin_access", False)), + account_status=_account_status(_row_value(row, "account_status", "approved")), cohort_ids=_cohort_ids(_row_value(row, "cohort")), affiliation=row["affiliation"] or DEFAULT_AFFILIATION, + legal_name=_row_value(row, "legal_name", "") or "", + department=_row_value(row, "department", "") or "", + grade_level=_row_value(row, "grade_level", "") or "", + phone=_row_value(row, "phone", "") or "", + contact_address=_row_value(row, "contact_address", "") or "", + nickname=_row_value(row, "nickname", "") or "", + self_introduction=_row_value(row, "self_introduction", "") or "", + avatar_url=_row_value(row, "avatar_url", "") or "", consent_at=_optional_ts(_row_value(row, "consent_at")), + profile_completed_at=_optional_ts(_row_value(row, "profile_completed_at")), + terms_agreed_at=_optional_ts(_row_value(row, "terms_agreed_at")), + privacy_agreed_at=_optional_ts(_row_value(row, "privacy_agreed_at")), + terms_version=_row_value(row, "terms_version", "") or "", + privacy_version=_row_value(row, "privacy_version", "") or "", created_at=_ts(row["created_at"]), last_seen_at=_ts(row["last_seen_at"]), ) -def _memory_upsert_managed_user( - *, - email: str, - display_name: str, - role: str, - cohort_ids: list[str] | None = None, - user_id: str | None = None, - affiliation: str | None = None, - consent_at: float | None = None, - reactivate: bool = False, -) -> ManagedUser: +def _memory_upsert_managed_user(data: ManagedUserMemoryInput) -> ManagedUser: now = time.time() - normalized_email = _normalize_email(email) - if normalized_email in _inactive_emails and not reactivate: + normalized_email = _normalize_email(data.email) + if normalized_email in _inactive_emails and not data.reactivate: raise InactiveUserError("user is inactive") - if reactivate: + if data.reactivate: _inactive_emails.discard(normalized_email) - uid = user_id or _email_index.get(normalized_email) or user_id_from_email(normalized_email) + uid = data.user_id or _email_index.get(normalized_email) or user_id_from_email(normalized_email) current = _users.get(uid) user = ManagedUser( user_id=uid, email=normalized_email, - display_name=(display_name.strip() if display_name else "") or normalized_email, - role=role, - cohort_ids=list(cohort_ids or current.cohort_ids if current else cohort_ids or []), + display_name=(data.display_name.strip() if data.display_name else "") or normalized_email, + role=data.role, + admin_access=data.admin_access if data.admin_access is not None else (current.admin_access if current else False), + account_status=data.account_status or (current.account_status if current else "approved"), + cohort_ids=( + list(data.cohort_ids) + if data.cohort_ids is not None + else (list(current.cohort_ids) if current else []) + ), affiliation=( - affiliation.strip() - if affiliation + data.affiliation.strip() + if data.affiliation else (current.affiliation if current else DEFAULT_AFFILIATION) ), - consent_at=consent_at if consent_at is not None else (current.consent_at if current else None), + legal_name=( + data.legal_name.strip() + if data.legal_name is not None + else (current.legal_name if current else "") + ), + department=( + data.department.strip() + if data.department is not None + else (current.department if current else "") + ), + grade_level=( + data.grade_level.strip() + if data.grade_level is not None + else (current.grade_level if current else "") + ), + phone=data.phone.strip() if data.phone is not None else (current.phone if current else ""), + contact_address=( + data.contact_address.strip() + if data.contact_address is not None + else (current.contact_address if current else "") + ), + nickname=data.nickname.strip() if data.nickname is not None else (current.nickname if current else ""), + self_introduction=( + data.self_introduction.strip() + if data.self_introduction is not None + else (current.self_introduction if current else "") + ), + avatar_url=data.avatar_url.strip() if data.avatar_url is not None else (current.avatar_url if current else ""), + consent_at=data.consent_at if data.consent_at is not None else (current.consent_at if current else None), + profile_completed_at=( + data.profile_completed_at + if data.profile_completed_at is not None + else (current.profile_completed_at if current else None) + ), + terms_agreed_at=( + data.terms_agreed_at + if data.terms_agreed_at is not None + else (current.terms_agreed_at if current else None) + ), + privacy_agreed_at=( + data.privacy_agreed_at + if data.privacy_agreed_at is not None + else (current.privacy_agreed_at if current else None) + ), + terms_version=( + data.terms_version.strip() + if data.terms_version is not None + else (current.terms_version if current else "") + ), + privacy_version=( + data.privacy_version.strip() + if data.privacy_version is not None + else (current.privacy_version if current else "") + ), created_at=current.created_at if current else now, last_seen_at=now, ) @@ -453,35 +897,146 @@ async def upsert_managed_user( *, email: str, display_name: str, - role: str, + role: RoleName, + admin_access: bool | None = None, cohort_ids: list[str] | None = None, user_id: str | None = None, external_id: str | None = None, affiliation: str | None = None, + account_status: AccountStatus | None = None, reactivate: bool = False, ) -> ManagedUser: normalized_email = _normalize_email(email) normalized_external_id = _normalize_external_id(external_id, normalized_email) + manual_external_id = f"email:{normalized_email}" + desired_account_status = account_status or _initial_account_status( + email=normalized_email, + external_id=normalized_external_id, + reactivate=reactivate, + ) try: pool = get_pool() async with pool.acquire() as conn: + if user_id is None and normalized_external_id != manual_external_id: + row = await conn.fetchrow( + """ + UPDATE app.app_user SET + external_id = $1, + display_name = COALESCE(NULLIF(display_name, ''), $3), + cohort = COALESCE(cohort, $4), + affiliation = COALESCE(NULLIF(affiliation, ''), $5), + last_seen_at = now(), + updated_at = now() + WHERE user_id = ( + SELECT user_id + FROM app.app_user + WHERE lower(email) = $2 + AND is_active + AND (external_id = $6 OR external_id IS NULL) + AND NOT EXISTS ( + SELECT 1 FROM app.app_user existing + WHERE existing.external_id = $1 + ) + ORDER BY last_seen_at DESC + LIMIT 1 + ) + RETURNING + user_id, + email, + display_name, + role, + admin_access, + account_status, + cohort, + affiliation, + legal_name, + department, + grade_level, + phone, + contact_address, + nickname, + self_introduction, + avatar_url, + consent_at, + profile_completed_at, + terms_agreed_at, + privacy_agreed_at, + terms_version, + privacy_version, + created_at, + last_seen_at + """, + normalized_external_id, + normalized_email, + (display_name.strip() if display_name else normalized_email), + _cohort_value(cohort_ids), + affiliation or DEFAULT_AFFILIATION, + manual_external_id, + ) + if row is not None: + user = _managed_user_from_row(row) + _memory_upsert_managed_user(ManagedUserMemoryInput.from_user(user, reactivate=True)) + return user row = await conn.fetchrow( """ INSERT INTO app.app_user ( - external_id, email, display_name, role, cohort, affiliation, last_seen_at, updated_at + external_id, + email, + display_name, + role, + admin_access, + cohort, + affiliation, + account_status, + last_seen_at, + updated_at ) - VALUES ($1, $2, $3, $4, $5, $6, now(), now()) + VALUES ($1, $2, $3, $4, COALESCE($9, false), $5, $6, $8, now(), now()) ON CONFLICT (external_id) DO UPDATE SET email = EXCLUDED.email, display_name = COALESCE(NULLIF(EXCLUDED.display_name, ''), app.app_user.display_name), role = EXCLUDED.role, + admin_access = CASE + WHEN $9 IS NULL THEN app.app_user.admin_access + ELSE EXCLUDED.admin_access + END, cohort = COALESCE(EXCLUDED.cohort, app.app_user.cohort), affiliation = COALESCE(NULLIF(EXCLUDED.affiliation, ''), app.app_user.affiliation), is_active = CASE WHEN $7 THEN TRUE ELSE app.app_user.is_active END, + account_status = CASE + WHEN app.app_user.account_status = 'suspended' THEN 'suspended' + WHEN EXCLUDED.account_status = 'approved' THEN 'approved' + WHEN app.app_user.account_status = 'approved' THEN 'approved' + ELSE EXCLUDED.account_status + END, last_seen_at = now(), updated_at = now() WHERE app.app_user.is_active OR $7 - RETURNING user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at + RETURNING + user_id, + email, + display_name, + role, + admin_access, + account_status, + cohort, + affiliation, + legal_name, + department, + grade_level, + phone, + contact_address, + nickname, + self_introduction, + avatar_url, + consent_at, + profile_completed_at, + terms_agreed_at, + privacy_agreed_at, + terms_version, + privacy_version, + created_at, + last_seen_at """, normalized_external_id, normalized_email, @@ -490,34 +1045,39 @@ async def upsert_managed_user( _cohort_value(cohort_ids), affiliation or DEFAULT_AFFILIATION, reactivate, + desired_account_status, + admin_access, ) if row is None: _inactive_emails.add(normalized_email) raise InactiveUserError("user is inactive") user = _managed_user_from_row(row) - _memory_upsert_managed_user( - email=user.email, - display_name=user.display_name, - role=user.role, - cohort_ids=user.cohort_ids, - user_id=user.user_id, - affiliation=user.affiliation, - consent_at=user.consent_at, - reactivate=True, - ) + _memory_upsert_managed_user(ManagedUserMemoryInput.from_user(user, reactivate=True)) return user except InactiveUserError: raise except Exception: require_runtime_fallback_allowed("managed user") + current = _users.get(user_id or "") or _users.get(_email_index.get(normalized_email, "")) + fallback_uid = user_id or (current.user_id if current is not None else user_id_from_external_id(normalized_external_id)) + fallback_account_status = desired_account_status + if current is not None: + if current.account_status == "suspended": + fallback_account_status = "suspended" + elif current.account_status == "approved" and desired_account_status == "pending": + fallback_account_status = "approved" return _memory_upsert_managed_user( - email=normalized_email, - display_name=display_name, - role=role, - cohort_ids=cohort_ids, - user_id=user_id or user_id_from_external_id(normalized_external_id), - affiliation=affiliation, - reactivate=reactivate, + ManagedUserMemoryInput( + email=normalized_email, + display_name=display_name, + role=role, + admin_access=admin_access, + account_status=fallback_account_status, + cohort_ids=cohort_ids, + user_id=fallback_uid, + affiliation=affiliation, + reactivate=reactivate, + ) ) @@ -527,7 +1087,31 @@ async def get_managed_user(user_id: str) -> ManagedUser | None: async with pool.acquire() as conn: row = await conn.fetchrow( """ - SELECT user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at + SELECT + user_id, + email, + display_name, + role, + admin_access, + account_status, + cohort, + affiliation, + legal_name, + department, + grade_level, + phone, + contact_address, + nickname, + self_introduction, + avatar_url, + consent_at, + profile_completed_at, + terms_agreed_at, + privacy_agreed_at, + terms_version, + privacy_version, + created_at, + last_seen_at FROM app.app_user WHERE user_id = $1::uuid AND is_active """, @@ -542,13 +1126,92 @@ async def get_managed_user(user_id: str) -> ManagedUser | None: return _users.get(user_id) +async def get_managed_user_by_email(email: str) -> ManagedUser | None: + normalized_email = _normalize_email(email) + if not normalized_email: + return None + manual_external_id = f"email:{normalized_email}" + try: + pool = get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT + user_id, + email, + display_name, + role, + admin_access, + account_status, + cohort, + affiliation, + legal_name, + department, + grade_level, + phone, + contact_address, + nickname, + self_introduction, + avatar_url, + consent_at, + profile_completed_at, + terms_agreed_at, + privacy_agreed_at, + terms_version, + privacy_version, + created_at, + last_seen_at + FROM app.app_user + WHERE lower(email) = $1 AND is_active + ORDER BY + CASE WHEN external_id = $2 THEN 0 ELSE 1 END, + last_seen_at DESC + LIMIT 1 + """, + normalized_email, + manual_external_id, + ) + if row is not None: + return _managed_user_from_row(row) + except Exception: + require_runtime_fallback_allowed("managed user lookup") + if not runtime_fallback_allowed(): + return None + uid = _email_index.get(normalized_email) + return _users.get(uid or "") + + async def list_managed_users() -> tuple[list[ManagedUser], bool]: try: pool = get_pool() async with pool.acquire() as conn: rows = await conn.fetch( """ - SELECT user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at + SELECT + user_id, + email, + display_name, + role, + admin_access, + account_status, + cohort, + affiliation, + legal_name, + department, + grade_level, + phone, + contact_address, + nickname, + self_introduction, + avatar_url, + consent_at, + profile_completed_at, + terms_agreed_at, + privacy_agreed_at, + terms_version, + privacy_version, + created_at, + last_seen_at FROM app.app_user WHERE is_active ORDER BY last_seen_at DESC @@ -562,11 +1225,7 @@ async def list_managed_users() -> tuple[list[ManagedUser], bool]: async def update_managed_user( user_id: str, - *, - display_name: str | None = None, - role: RoleName | None = None, - affiliation: str | None = None, - cohort_ids: list[str] | None = None, + patch: ManagedUserPatch, ) -> ManagedUser | None: try: pool = get_pool() @@ -576,37 +1235,89 @@ async def update_managed_user( UPDATE app.app_user SET display_name = COALESCE($2, display_name), role = COALESCE($3, role), + account_status = COALESCE($18, account_status), + admin_access = COALESCE($19, admin_access), cohort = CASE WHEN $4 THEN $5 ELSE cohort END, affiliation = COALESCE($6, affiliation), + legal_name = COALESCE($7, legal_name), + department = COALESCE($8, department), + grade_level = COALESCE($9, grade_level), + phone = COALESCE($10, phone), + contact_address = COALESCE($11, contact_address), + nickname = COALESCE($12, nickname), + self_introduction = COALESCE($13, self_introduction), + avatar_url = COALESCE($14, avatar_url), + profile_completed_at = CASE WHEN $15 THEN now() ELSE profile_completed_at END, + terms_agreed_at = CASE WHEN $15 THEN now() ELSE terms_agreed_at END, + privacy_agreed_at = CASE WHEN $15 THEN now() ELSE privacy_agreed_at END, + terms_version = CASE WHEN $15 THEN COALESCE($16, terms_version) ELSE terms_version END, + privacy_version = CASE WHEN $15 THEN COALESCE($17, privacy_version) ELSE privacy_version END, updated_at = now(), last_seen_at = now() WHERE user_id = $1::uuid AND is_active - RETURNING user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at + RETURNING + user_id, + email, + display_name, + role, + admin_access, + account_status, + cohort, + affiliation, + legal_name, + department, + grade_level, + phone, + contact_address, + nickname, + self_introduction, + avatar_url, + consent_at, + profile_completed_at, + terms_agreed_at, + privacy_agreed_at, + terms_version, + privacy_version, + created_at, + last_seen_at """, user_id, - display_name.strip() if display_name is not None else None, - _db_role(role) if role is not None else None, - cohort_ids is not None, - _cohort_value(cohort_ids), - affiliation.strip() if affiliation is not None else None, + patch.display_name.strip() if patch.display_name is not None else None, + _db_role(patch.role) if patch.role is not None else None, + patch.cohort_ids is not None, + _cohort_value(patch.cohort_ids), + patch.affiliation.strip() if patch.affiliation is not None else None, + patch.legal_name.strip() if patch.legal_name is not None else None, + patch.department.strip() if patch.department is not None else None, + patch.grade_level.strip() if patch.grade_level is not None else None, + patch.phone.strip() if patch.phone is not None else None, + patch.contact_address.strip() if patch.contact_address is not None else None, + patch.nickname.strip() if patch.nickname is not None else None, + patch.self_introduction.strip() if patch.self_introduction is not None else None, + patch.avatar_url.strip() if patch.avatar_url is not None else None, + patch.complete_onboarding, + patch.terms_version.strip() if patch.terms_version is not None else None, + patch.privacy_version.strip() if patch.privacy_version is not None else None, + patch.account_status, + patch.admin_access, ) if row is not None: next_user = _managed_user_from_row(row) - _memory_upsert_managed_user( - email=next_user.email, - display_name=next_user.display_name, - role=next_user.role, - cohort_ids=next_user.cohort_ids, - user_id=next_user.user_id, - affiliation=next_user.affiliation, - consent_at=next_user.consent_at, - ) + _memory_upsert_managed_user(ManagedUserMemoryInput.from_user(next_user)) for session in _sessions.values(): if session.user_id == user_id: session.display_name = next_user.display_name session.role = next_user.role + session.admin_access = has_admin_access( + next_user.email, + next_user.role, + next_user.admin_access, + ) + session.super_admin = is_super_admin_email(next_user.email) + session.account_status = next_user.account_status session.cohort_ids = list(next_user.cohort_ids) session.consent_at = next_user.consent_at + session.profile_completed_at = next_user.profile_completed_at return next_user except Exception: require_runtime_fallback_allowed("managed user update") @@ -620,11 +1331,42 @@ async def update_managed_user( next_user = ManagedUser( user_id=current.user_id, email=current.email, - display_name=display_name.strip() if display_name is not None else current.display_name, - role=role if role is not None else current.role, - cohort_ids=list(cohort_ids) if cohort_ids is not None else current.cohort_ids, - affiliation=affiliation.strip() if affiliation is not None else current.affiliation, + display_name=patch.display_name.strip() if patch.display_name is not None else current.display_name, + role=patch.role if patch.role is not None else current.role, + admin_access=patch.admin_access if patch.admin_access is not None else current.admin_access, + account_status=patch.account_status if patch.account_status is not None else current.account_status, + cohort_ids=list(patch.cohort_ids) if patch.cohort_ids is not None else current.cohort_ids, + affiliation=patch.affiliation.strip() if patch.affiliation is not None else current.affiliation, + legal_name=patch.legal_name.strip() if patch.legal_name is not None else current.legal_name, + department=patch.department.strip() if patch.department is not None else current.department, + grade_level=patch.grade_level.strip() if patch.grade_level is not None else current.grade_level, + phone=patch.phone.strip() if patch.phone is not None else current.phone, + contact_address=( + patch.contact_address.strip() + if patch.contact_address is not None + else current.contact_address + ), + nickname=patch.nickname.strip() if patch.nickname is not None else current.nickname, + self_introduction=( + patch.self_introduction.strip() + if patch.self_introduction is not None + else current.self_introduction + ), + avatar_url=patch.avatar_url.strip() if patch.avatar_url is not None else current.avatar_url, consent_at=current.consent_at, + profile_completed_at=time.time() if patch.complete_onboarding else current.profile_completed_at, + terms_agreed_at=time.time() if patch.complete_onboarding else current.terms_agreed_at, + privacy_agreed_at=time.time() if patch.complete_onboarding else current.privacy_agreed_at, + terms_version=( + patch.terms_version.strip() + if patch.complete_onboarding and patch.terms_version is not None + else current.terms_version + ), + privacy_version=( + patch.privacy_version.strip() + if patch.complete_onboarding and patch.privacy_version is not None + else current.privacy_version + ), created_at=current.created_at, last_seen_at=time.time(), ) @@ -634,8 +1376,16 @@ async def update_managed_user( if session.user_id == user_id: session.display_name = next_user.display_name session.role = next_user.role + session.admin_access = has_admin_access( + next_user.email, + next_user.role, + next_user.admin_access, + ) + session.super_admin = is_super_admin_email(next_user.email) + session.account_status = next_user.account_status session.cohort_ids = list(next_user.cohort_ids) session.consent_at = next_user.consent_at + session.profile_completed_at = next_user.profile_completed_at return next_user @@ -659,15 +1409,9 @@ async def record_user_consent(user_id: str) -> float | None: consent_at = _optional_ts(row["consent_at"]) memory_user = _users.get(user_id) if memory_user is not None: - _users[user_id] = ManagedUser( - user_id=memory_user.user_id, - email=memory_user.email, - display_name=memory_user.display_name, - role=memory_user.role, - cohort_ids=list(memory_user.cohort_ids), - affiliation=memory_user.affiliation, + _users[user_id] = replace( + memory_user, consent_at=consent_at, - created_at=memory_user.created_at, last_seen_at=time.time(), ) for session in _sessions.values(): @@ -683,17 +1427,7 @@ async def record_user_consent(user_id: str) -> float | None: if current is None: return None consent_at = time.time() - _users[user_id] = ManagedUser( - user_id=current.user_id, - email=current.email, - display_name=current.display_name, - role=current.role, - cohort_ids=list(current.cohort_ids), - affiliation=current.affiliation, - consent_at=consent_at, - created_at=current.created_at, - last_seen_at=consent_at, - ) + _users[user_id] = replace(current, consent_at=consent_at, last_seen_at=consent_at) for session in _sessions.values(): if session.user_id == user_id: session.consent_at = consent_at @@ -724,17 +1458,7 @@ async def withdraw_user_consent(user_id: str) -> bool: return changed current = _users.get(user_id) if current is not None: - _users[user_id] = ManagedUser( - user_id=current.user_id, - email=current.email, - display_name=current.display_name, - role=current.role, - cohort_ids=list(current.cohort_ids), - affiliation=current.affiliation, - consent_at=None, - created_at=current.created_at, - last_seen_at=time.time(), - ) + _users[user_id] = replace(current, consent_at=None, last_seen_at=time.time()) changed = True for session in _sessions.values(): if session.user_id == user_id: @@ -747,6 +1471,18 @@ async def user_has_consent(user_id: str) -> bool: return bool(managed and managed.consent_at is not None) +async def user_onboarding_complete(user_id: str) -> bool: + managed = await get_managed_user(user_id) + return bool( + managed + and managed.profile_completed_at is not None + and managed.terms_agreed_at is not None + and managed.privacy_agreed_at is not None + and bool(managed.nickname.strip()) + and bool(managed.self_introduction.strip()) + ) + + async def deactivate_managed_user(user_id: str) -> bool: """Deactivate a managed user and revoke their active browser sessions.""" changed = False @@ -825,7 +1561,7 @@ async def create_session( *, email: str, display_name: str, - role: str, + role: RoleName, cohort_ids: list[str] | None = None, user_id: str | None = None, external_id: str | None = None, @@ -847,8 +1583,12 @@ async def create_session( email=normalized_email, display_name=managed.display_name, role=managed.role, + admin_access=has_admin_access(managed.email, managed.role, managed.admin_access), + super_admin=is_super_admin_email(managed.email), + account_status=managed.account_status, cohort_ids=list(managed.cohort_ids), consent_at=managed.consent_at, + profile_completed_at=managed.profile_completed_at, expires_at=expires_at, ) sid_hash = _sid_hash(raw_sid) @@ -898,8 +1638,11 @@ async def get_session(raw_sid: str | None) -> SessionUser | None: u.email, COALESCE(u.display_name, s.display_name, u.email) AS display_name, u.role, + u.admin_access, + u.account_status, u.cohort, - u.consent_at + u.consent_at, + u.profile_completed_at FROM app.auth_session s JOIN app.app_user u ON u.user_id = s.user_id WHERE s.sid_hash = $1 @@ -923,8 +1666,16 @@ async def get_session(raw_sid: str | None) -> SessionUser | None: email=row["email"], display_name=row["display_name"], role=_app_role(row["role"]), + admin_access=has_admin_access( + row["email"], + _app_role(row["role"]), + bool(_row_value(row, "admin_access", False)), + ), + super_admin=is_super_admin_email(row["email"]), + account_status=_account_status(row["account_status"]), cohort_ids=_cohort_ids(row["cohort"]), consent_at=_optional_ts(row["consent_at"]), + profile_completed_at=_optional_ts(row["profile_completed_at"]), expires_at=_ts(row["expires_at"]), ) except Exception: @@ -941,6 +1692,14 @@ async def get_session(raw_sid: str | None) -> SessionUser | None: managed = _users.get(user.user_id) if managed is not None: managed.last_seen_at = time.time() + user.display_name = managed.display_name + user.role = managed.role + user.admin_access = has_admin_access(managed.email, managed.role, managed.admin_access) + user.super_admin = is_super_admin_email(managed.email) + user.account_status = managed.account_status + user.cohort_ids = list(managed.cohort_ids) + user.consent_at = managed.consent_at + user.profile_completed_at = managed.profile_completed_at return user diff --git a/apps/api/app/routes/admin.py b/apps/api/app/routes/admin.py index 8ab13ba..10566e5 100644 --- a/apps/api/app/routes/admin.py +++ b/apps/api/app/routes/admin.py @@ -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) diff --git a/apps/api/app/routes/auth.py b/apps/api/app/routes/auth.py index 75ae102..e36ae0f 100644 --- a/apps/api/app/routes/auth.py +++ b/apps/api/app/routes/auth.py @@ -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) diff --git a/apps/api/app/test_auth_providers.py b/apps/api/app/test_auth_providers.py index e2e3f39..ca92e3c 100644 --- a/apps/api/app/test_auth_providers.py +++ b/apps/api/app/test_auth_providers.py @@ -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( [ diff --git a/apps/web/src/pages/Admin.tsx b/apps/web/src/pages/Admin.tsx index 8dc4381..506ee33 100644 --- a/apps/web/src/pages/Admin.tsx +++ b/apps/web/src/pages/Admin.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { AppShell } from "../components/shell/AppShell"; import { Badge, Button, Dot, Icon, Kicker, ProgressBar } from "../components/ui"; import { @@ -6,25 +6,88 @@ import { adminUsersApi, type AdminHealthResponse, type AdminHealthStatus, - type AdminUsageResponse, type AdminManagedUser, + type AdminSupportTicket, + type AdminTicketsResponse, + type AdminUsageResponse, + type AdminUptimeResponse, type AdminUserCreateRequest, type AdminUsersResponse, } from "../lib/api"; +import { useAuth } from "../lib/auth"; +import "./admin/admin-console.css"; -type UserDraft = Pick; +export type AdminSection = "overview" | "users" | "access" | "tickets"; + +type UserDraft = Pick< + AdminManagedUser, + "display_name" | "role" | "admin_access" | "account_status" | "affiliation" | "cohort_ids" +>; type NewUserDraft = Required> & - Pick; + Pick; +type UserTab = "approval" | "manage" | "register" | "activity"; +type AccessTab = "roles" | "groups" | "matrix"; + const MAX_RENDERED_USERS = 40; +const MAX_RESOLVED_TICKET_HISTORY = 6; const EMPTY_NEW_USER: NewUserDraft = { email: "", display_name: "", role: "learner", + admin_access: false, + account_status: "approved", affiliation: "", cohort_ids: [], }; +const ROLE_POLICIES = [ + { + role: "관리자", + scope: "운영 상태, 사용자, 권한 정책, 티켓 큐", + permissions: ["서비스 헬스", "사용자 등록", "권한 정책", "운영 티켓"], + risk: "학습자 상담 내용은 기본 운영 화면에서 제외", + }, + { + role: "교수자", + scope: "담당 코호트 수업 운영과 회기 리뷰", + permissions: ["담당 학습자 현황", "회기 리뷰", "안전 알림"], + risk: "담당 범위 밖 사용자/시스템 설정 접근 불가", + }, + { + role: "학습자", + scope: "본인 회기, 리뷰, 계정 설정", + permissions: ["상담 세션", "본인 리뷰", "개인 설정"], + risk: "타 사용자 데이터와 운영 리소스 접근 불가", + }, +]; + +const GROUP_POLICIES = [ + { + name: "서비스 운영자", + scope: "서비스 상태와 장애 티켓 1차 대응", + access: ["운영 콘솔", "티켓 큐", "사용자 읽기"], + }, + { + name: "학과 관리자", + scope: "사용자 등록과 코호트 배정", + access: ["사용자 관리", "그룹 배정", "코호트 관리"], + }, + { + name: "슈퍼바이저", + scope: "교수자 회기 리뷰 품질 점검", + access: ["교수 콘솔", "안전 알림", "리뷰 읽기"], + }, +]; + +const PERMISSION_MATRIX = [ + { resource: "운영 콘솔", admin: "전체", teacher: "없음", learner: "없음" }, + { resource: "사용자 관리", admin: "등록/수정", teacher: "담당자 읽기", learner: "본인만" }, + { resource: "회기 리뷰", admin: "감사 로그", teacher: "담당 코호트", learner: "본인 회기" }, + { resource: "AI 운영 설정", admin: "수정", teacher: "없음", learner: "없음" }, + { resource: "안전 알림", admin: "전체", teacher: "담당 코호트", learner: "없음" }, +]; + function toneOf(status: AdminHealthStatus): "pos" | "warn" | "crit" { if (status === "ok") return "pos"; if (status === "degraded") return "warn"; @@ -71,13 +134,25 @@ function roleBadgeTone(role: AdminManagedUser["role"]): "accent" | "neutral" | " return "neutral"; } +function accountStatusText(status: AdminManagedUser["account_status"]): string { + if (status === "approved") return "승인됨"; + if (status === "suspended") return "보류"; + return "승인 대기"; +} + +function accountStatusTone(status: AdminManagedUser["account_status"]): "accent" | "neutral" | "warn" | "crit" { + if (status === "approved") return "accent"; + if (status === "suspended") return "crit"; + return "warn"; +} + function storeLabel(data: AdminUsersResponse | null): string { if (!data) return "대기 중"; return data.durable ? "DB 사용자" : "비영구 런타임 저장소"; } -function dateTimeLabel(seconds: number): string { - if (!Number.isFinite(seconds) || seconds <= 0) return "-"; +function dateTimeLabel(seconds?: number | null): string { + if (!seconds || !Number.isFinite(seconds) || seconds <= 0) return "-"; return new Date(seconds * 1000).toLocaleString("ko-KR", { month: "2-digit", day: "2-digit", @@ -118,6 +193,53 @@ function usageBudgetDetail(data: AdminUsageResponse): string { return `${costLabel(data.cost_usd)} / ${costLabel(budget.limit_usd)} · ${pct}% 사용${remaining}`; } +function uptimeLabel(data: AdminUptimeResponse | null): string { + if (!data) return "-"; + if (!data.durable || data.sample_count === 0) return "샘플 없음"; + return `${Math.round(data.ok_ratio * 1000) / 10}%`; +} + +function lastDownLabel(data: AdminUptimeResponse | null): string { + if (!data) return "-"; + if (!data.durable) return "저장소 미연결"; + return data.last_down_at ? dateTimeLabel(data.last_down_at) : "기록 없음"; +} + +function ticketStatusLabel(status: AdminSupportTicket["status"]): string { + if (status === "open") return "미해결"; + if (status === "triaged") return "분류됨"; + if (status === "in_progress") return "처리 중"; + if (status === "resolved") return "해결"; + return "종결"; +} + +function ticketIsActive(ticket: AdminSupportTicket): boolean { + return ticket.status !== "resolved" && ticket.status !== "closed"; +} + +function ticketPriorityLabel(priority: AdminSupportTicket["priority"]): string { + if (priority === "urgent") return "긴급"; + if (priority === "high") return "높음"; + if (priority === "normal") return "보통"; + return "낮음"; +} + +function ticketCategoryLabel(category: AdminSupportTicket["category"]): string { + if (category === "account_access") return "계정/권한"; + if (category === "session_review") return "세션/리뷰"; + if (category === "voice_browser") return "음성/브라우저"; + if (category === "content_scenario") return "콘텐츠/시나리오"; + if (category === "safety") return "안전"; + return "기타"; +} + +function ticketTone(ticket: AdminSupportTicket): "accent" | "neutral" | "warn" | "crit" { + if (!ticketIsActive(ticket)) return "neutral"; + if (ticket.priority === "urgent") return "crit"; + if (ticket.priority === "high") return "warn"; + return "accent"; +} + function initialOf(user: AdminManagedUser): string { const label = user.display_name.trim() || user.email; return Array.from(label)[0]?.toUpperCase() ?? "?"; @@ -134,7 +256,37 @@ function parseCohorts(value: string): string[] { .filter(Boolean); } -export default function Admin() { +function isOnline(seconds?: number | null): boolean { + if (!seconds || seconds <= 0) return false; + return Date.now() / 1000 - seconds < 15 * 60; +} + +interface PageHeaderProps { + kicker: string; + title: string; + description: string; + children?: ReactNode; +} + +function PageHeader({ kicker, title, description, children }: PageHeaderProps) { + return ( +
+
+ {kicker} +

{title}

+

{description}

+
+ {children ?
{children}
: null} +
+ ); +} + +interface AdminProps { + section?: AdminSection; +} + +export default function Admin({ section = "overview" }: AdminProps) { + const { user: currentUser } = useAuth(); const [health, setHealth] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -151,6 +303,16 @@ export default function Admin() { const [usage, setUsage] = useState(null); const [usageLoading, setUsageLoading] = useState(true); const [usageError, setUsageError] = useState(null); + const [uptime, setUptime] = useState(null); + const [uptimeLoading, setUptimeLoading] = useState(true); + const [uptimeError, setUptimeError] = useState(null); + const [tickets, setTickets] = useState(null); + const [ticketsLoading, setTicketsLoading] = useState(true); + const [ticketsError, setTicketsError] = useState(null); + const [updatingTicketId, setUpdatingTicketId] = useState(null); + const [userTab, setUserTab] = useState("approval"); + const [accessTab, setAccessTab] = useState("roles"); + const canGrantAdminAccess = currentUser?.superAdmin === true; const loadHealth = useCallback(async () => { setLoading(true); @@ -179,6 +341,8 @@ export default function Admin() { current[user.user_id] ?? { display_name: user.display_name, role: user.role, + admin_access: user.admin_access, + account_status: user.account_status, affiliation: user.affiliation, cohort_ids: user.cohort_ids, }, @@ -204,15 +368,41 @@ export default function Admin() { } }, []); + const loadUptime = useCallback(async () => { + setUptimeLoading(true); + setUptimeError(null); + try { + setUptime(await adminApi.uptime(24)); + } catch (err) { + setUptimeError(err instanceof Error ? err.message : "업타임 이력을 불러오지 못했습니다."); + } finally { + setUptimeLoading(false); + } + }, []); + + const loadTickets = useCallback(async () => { + setTicketsLoading(true); + setTicketsError(null); + try { + setTickets(await adminApi.tickets(undefined, 30)); + } catch (err) { + setTicketsError(err instanceof Error ? err.message : "운영 티켓을 불러오지 못했습니다."); + } finally { + setTicketsLoading(false); + } + }, []); + useEffect(() => { void loadHealth(); void loadUsers(); void loadUsage(); - }, [loadHealth, loadUsage, loadUsers]); + void loadUptime(); + void loadTickets(); + }, [loadHealth, loadTickets, loadUptime, loadUsage, loadUsers]); const refreshAll = useCallback(async () => { - await Promise.all([loadHealth(), loadUsers(), loadUsage()]); - }, [loadHealth, loadUsage, loadUsers]); + await Promise.all([loadHealth(), loadUsers(), loadUsage(), loadUptime(), loadTickets()]); + }, [loadHealth, loadTickets, loadUptime, loadUsage, loadUsers]); const updateDraft = (userId: string, patch: Partial) => { setUserDrafts((current) => ({ @@ -221,6 +411,8 @@ export default function Admin() { ...(current[userId] ?? { display_name: "", role: "learner", + admin_access: false, + account_status: "approved", affiliation: "", cohort_ids: [], }), @@ -251,6 +443,8 @@ export default function Admin() { [updated.user_id]: { display_name: updated.display_name, role: updated.role, + admin_access: updated.admin_access, + account_status: updated.account_status, affiliation: updated.affiliation, cohort_ids: updated.cohort_ids, }, @@ -281,6 +475,7 @@ export default function Admin() { }); setNewUser(EMPTY_NEW_USER); await loadUsers(); + setUserTab("manage"); } catch (err) { setUsersError(err instanceof Error ? err.message : "사용자를 등록하지 못했습니다."); } finally { @@ -288,6 +483,44 @@ export default function Admin() { } }; + const updateUserAccountStatus = async ( + user: AdminManagedUser, + accountStatus: AdminManagedUser["account_status"], + ) => { + setSavingUserId(user.user_id); + setUsersError(null); + try { + const updated = await adminUsersApi.update(user.user_id, { + account_status: accountStatus, + }); + setUsersData((current) => + current + ? { + ...current, + users: current.users.map((item) => + item.user_id === updated.user_id ? updated : item, + ), + } + : current, + ); + setUserDrafts((current) => ({ + ...current, + [updated.user_id]: { + display_name: updated.display_name, + role: updated.role, + admin_access: updated.admin_access, + account_status: updated.account_status, + affiliation: updated.affiliation, + cohort_ids: updated.cohort_ids, + }, + })); + } catch (err) { + setUsersError(err instanceof Error ? err.message : "계정 승인 상태를 저장하지 못했습니다."); + } finally { + setSavingUserId(null); + } + }; + const deactivateUser = async (user: AdminManagedUser) => { setDeactivatingUserId(user.user_id); setUsersError(null); @@ -313,18 +546,98 @@ export default function Admin() { } }; - const counts = useMemo(() => { - const services = health?.services ?? []; - return { + const updateTicketStatus = async ( + ticket: AdminSupportTicket, + status: AdminSupportTicket["status"], + ) => { + setUpdatingTicketId(ticket.ticket_id); + setTicketsError(null); + try { + const updated = await adminApi.updateTicket(ticket.ticket_id, { status }); + setTickets((current) => + current + ? { + ...current, + tickets: current.tickets.map((item) => + item.ticket_id === updated.ticket_id ? updated : item, + ), + } + : current, + ); + await loadTickets(); + } catch (err) { + setTicketsError(err instanceof Error ? err.message : "티켓 상태를 저장하지 못했습니다."); + } finally { + setUpdatingTicketId(null); + } + }; + + const services = health?.services ?? []; + const users = usersData?.users ?? []; + const counts = useMemo( + () => ({ ok: services.filter((service) => service.status === "ok").length, degraded: services.filter((service) => service.status === "degraded").length, down: services.filter((service) => service.status === "down").length, total: services.length, - }; - }, [health]); + }), + [services], + ); + const roleCounts = useMemo( + () => + users.reduce>( + (acc, user) => { + acc[user.role] += 1; + return acc; + }, + { learner: 0, teacher: 0, admin: 0 }, + ), + [users], + ); + const activeSessions = useMemo( + () => users.reduce((sum, user) => sum + Math.max(0, user.active_sessions), 0), + [users], + ); + const accountCounts = useMemo( + () => + users.reduce>( + (acc, user) => { + acc[user.account_status] += 1; + return acc; + }, + { pending: 0, approved: 0, suspended: 0 }, + ), + [users], + ); + const pendingUsers = useMemo( + () => + users + .filter((user) => user.account_status === "pending") + .sort((a, b) => b.created_at - a.created_at), + [users], + ); + const onlineUsers = useMemo( + () => users.filter((user) => isOnline(user.last_seen_at)).length, + [users], + ); + const incidentCount = counts.degraded + counts.down; + const healthTone = health ? toneOf(health.status) : "warn"; + const usersWritable = usersData?.durable === true; + const ticketSummary = tickets?.summary; + const openTickets = ticketSummary?.open_count ?? 0; + const activeTickets = useMemo( + () => (tickets?.tickets ?? []).filter(ticketIsActive), + [tickets], + ); + const recentResolvedTickets = useMemo( + () => + (tickets?.tickets ?? []) + .filter((ticket) => !ticketIsActive(ticket)) + .slice(0, MAX_RESOLVED_TICKET_HISTORY), + [tickets], + ); const filteredUsers = useMemo(() => { const query = userSearch.trim().toLowerCase(); - const users = usersData?.users ?? []; if (!query) return users; return users.filter((user) => [ @@ -332,6 +645,9 @@ export default function Admin() { user.display_name, user.role, roleText(user.role), + user.admin_access ? "관리자 권한" : "", + user.super_admin ? "슈퍼 관리자" : "", + accountStatusText(user.account_status), user.affiliation, cohortInputValue(user.cohort_ids), ] @@ -339,1265 +655,1007 @@ export default function Admin() { .toLowerCase() .includes(query), ); - }, [userSearch, usersData?.users]); + }, [userSearch, users]); const hasUserSearch = userSearch.trim().length > 0; const visibleUsers = filteredUsers.slice(0, MAX_RENDERED_USERS); - const usersWritable = usersData?.durable === true; - return ( - - -
-
-
- 운영 콘솔 -

서비스 상태

-

대화 생성, 저장소, 음성, 리뷰, 사용자 접근 상태를 한 화면에서 확인합니다.

+ const renderOverview = () => ( + <> + + + + +
+
+ +
+
+ 진행 중 교육 + {countLabel(activeSessions)} + 활성 회기 +
+
+ 온라인 사용자 + {countLabel(onlineUsers)} + 최근 15분 기준 +
+
+ 등록 사용자 + {countLabel(users.length)} + {storeLabel(usersData)} +
+
+ 서비스 헬스 + {counts.total ? `${counts.ok}/${counts.total}` : "-"} + {incidentCount ? `${incidentCount}개 점검` : "정상"} +
+
+ +
+
+
+

실시간 교육 흐름

+ 사용자 API 기반
- -
+
+
+ 학습자 회기 + {countLabel(activeSessions)} + 현재 진행 중인 상담 시뮬레이션 +
+
+ 교수자 접속 + {countLabel(users.filter((user) => user.role === "teacher" && isOnline(user.last_seen_at)).length)} + 리뷰·안전 알림 대응 가능 인원 +
+
+ 관리자 접속 + {countLabel(users.filter((user) => user.role === "admin" && isOnline(user.last_seen_at)).length)} + 운영 대응 가능 인원 +
+
+ AI 계량 턴 + {usage ? countLabel(usage.metered_turns) : "-"} + {usage ? `최근 ${usage.window_days}일` : "계산 중"} +
+
+ -
-
-
+
+ 샘플 정상률 + {uptimeLoading ? "확인 중" : uptimeLabel(uptime)} +
+
+ 최근 중단 + {lastDownLabel(uptime)} +
+ + {uptimeError ? : null} + -
-
- 전체 서비스 - {counts.total} - 등록 리소스 -
-
- 정상 - {counts.ok} - 즉시 사용 가능 -
-
- 제한 운영 - {counts.degraded} - 확인 필요 -
-
- 중단 - {counts.down} - 운영 개입 -
-
-
- -
+
-

AI 비용 관측

- - {usage - ? `최근 ${usage.window_days}일 · ${usageSourceLabel(usage)}` - : usageSourceLabel(usage)} - +

AI 비용

+ {usageSourceLabel(usage)}
+ {usageError ? : null} +
+ {usage ? costLabel(usage.cost_usd) : "-"} + {usage ? usageBudgetLabel(usage) : "계산 중"} + {usage ? {usageBudgetDetail(usage)} : null} +
+ {usage ? : null} +
- {usageError ? ( -
- - {usageError} -
+
+
+

운영 티켓

+ {tickets?.durable ? "DB 큐" : "대기 중"} +
+ {ticketsError ? : null} + {ticketsLoading && !tickets ? ( + ) : null} - -
-
- 누적 비용 - {usage ? costLabel(usage.cost_usd) : "-"} - {usage ? `${countLabel(usage.metered_turns)}개 계량 턴` : "계산 중"} -
-
- 입력 토큰 - {usage ? countLabel(usage.tokens_in) : "-"} - 프롬프트/컨텍스트 -
-
- 출력 토큰 - {usage ? countLabel(usage.tokens_out) : "-"} - 내담자 응답 -
-
- 계량 커버리지 - - {usage && usage.total_turns > 0 - ? `${Math.round((usage.metered_turns / usage.total_turns) * 100)}%` - : usage - ? "0%" - : "-"} - - {usage ? `${countLabel(usage.total_turns)}개 내담자 턴` : "계산 중"} -
-
- - {usage ? ( -
- + {!ticketsLoading && tickets && activeTickets.length === 0 ? ( + + ) : null} + {tickets && activeTickets.length > 0 ? ( +
- {usageBudgetLabel(usage)} - {usageBudgetDetail(usage)} -
-
- ) : null} - -
-
- Provider / Model - - 토큰 - 비용 -
- {(usage?.by_provider ?? []).map((item) => ( -
+ {countLabel(tickets.summary.open_count)}건 미해결 - {item.provider} - {item.model} + 높은 우선순위 {countLabel(tickets.summary.high_priority_count)}건 · 24시간 이상 정체{" "} + {countLabel(tickets.summary.stale_count)}건 - {countLabel(item.turns)} - {countLabel(item.tokens_in + item.tokens_out)} - {costLabel(item.cost_usd)}
- ))} - {usageLoading && !usage ? ( -
비용 사용량을 계산하는 중입니다.
- ) : null} - {!usageLoading && usage && usage.by_provider.length === 0 ? ( -
최근 윈도우에 계량된 AI 턴이 없습니다.
- ) : null} -
-
- - {error ? ( -
- - {error} -
- ) : null} - -
-
-

서비스 리소스

- {health ? `${health.services.length}개 항목` : "대기 중"} -
-
- {(health?.services ?? []).map((service) => ( -
-
-
- - {service.name} -
- {statusLabel(service.status)} + {activeTickets.slice(0, 2).map((ticket) => ( +
+ {ticket.subject} + + {ticketStatusLabel(ticket.status)} · {ticketPriorityLabel(ticket.priority)} ·{" "} + {ticket.reporter.email} +
-

{service.detail}

-
- - {service.metric} -
-
- ))} - {loading && !health - ? Array.from({ length: 3 }).map((_, index) => ( - - )) - : null} - {!loading && health && health.services.length === 0 ? ( -
표시할 서비스 리소스가 없습니다.
- ) : null} - {!loading && !health ? ( -
서비스 상태를 불러오지 못했습니다. 새로고침을 눌러 다시 시도하세요.
- ) : null} -
-
- -
-
-

사용자 관리

- - {usersData - ? `${usersData.users.length}명 · ${storeLabel(usersData)}` - : storeLabel(usersData)} - -
- - {usersError ? ( -
- - {usersError} -
+ ))} + ) : null} + +
-
- - -
-
- - {usersData - ? `${visibleUsers.length}/${filteredUsers.length}명 표시 · 전체 ${usersData.users.length}명` - : "사용자 확인 중"} -
-
- {visibleUsers.map((user) => { - const draft = userDrafts[user.user_id] ?? { - display_name: user.display_name, - role: user.role, - affiliation: user.affiliation, - cohort_ids: user.cohort_ids, - }; - const dirty = - draft.display_name !== user.display_name || - draft.role !== user.role || - draft.affiliation !== user.affiliation || - cohortInputValue(draft.cohort_ids) !== cohortInputValue(user.cohort_ids); - return ( -
-
-
- {initialOf(user)} -
- {user.display_name} -

{user.email}

-
+
+ 활성 세션 {user.active_sessions} + {accountStatusText(user.account_status)} + {isOnline(user.last_seen_at) ? "온라인" : "오프라인"} + 최근 {dateTimeLabel(user.last_seen_at)} +
+ +
+ + +
+
+ ); + })} + + {usersLoading && !usersData + ? Array.from({ length: 2 }).map((_, index) => ( + + )) + : null} + {!usersLoading && !usersData ? ( +
+ 사용자 목록을 불러오지 못했습니다. 새로고침을 눌러 다시 시도하세요. +
+ ) : null} + {!usersLoading && usersData && filteredUsers.length > visibleUsers.length ? ( +
+ 검색어를 입력하면 나머지 {filteredUsers.length - visibleUsers.length}명을 더 좁혀 볼 수 있습니다. +
+ ) : null} + {!usersLoading && usersData && filteredUsers.length === 0 ? ( +
+ {hasUserSearch ? "검색 조건에 맞는 사용자가 없습니다." : "아직 등록된 사용자가 없습니다."} +
+ ) : null} +
+ + ); + + const renderUserActivity = () => ( +
+
+
+

역할 분포

+ {storeLabel(usersData)} +
+
+ + + +
+
+
+
+

최근 활동

+ 사용자 last_seen 기준 +
+
+ {users + .slice() + .sort((a, b) => b.last_seen_at - a.last_seen_at) + .slice(0, 8) + .map((user) => ( +
+ + {user.display_name} + {user.email} + + {roleText(user.role)} + {user.active_sessions}회기 + {dateTimeLabel(user.last_seen_at)} +
+ ))} + {!usersLoading && users.length === 0 ? ( +
활동을 표시할 사용자가 없습니다.
+ ) : null} +
+
+
+ ); + + const renderAccess = () => ( + <> + +
+ 역할·그룹 저장 API는 아직 분리되지 않았습니다. 현재 화면은 운영 정책 기준을 먼저 고정합니다. +
+ + + {accessTab === "roles" ? ( +
+ {ROLE_POLICIES.map((policy) => ( +
+
+

{policy.role}

+ {policy.permissions.length}개 권한 +
+

{policy.scope}

+
+ {policy.permissions.map((permission) => ( + {permission} + ))} +
+ {policy.risk} +
+ ))} +
+ ) : null} + + {accessTab === "groups" ? ( +
+ {GROUP_POLICIES.map((group) => ( +
+
+

{group.name}

+ 정책 초안 +
+

{group.scope}

+
+ {group.access.map((item) => ( + {item} + ))} +
+
+ ))} +
+ ) : null} + + {accessTab === "matrix" ? ( +
+
+
+ 리소스 + 관리자 + 교수자 + 학습자 +
+ {PERMISSION_MATRIX.map((row) => ( +
+ {row.resource} + {row.admin} + {row.teacher} + {row.learner} +
+ ))} +
+
+ ) : null} + + ); + + const renderTickets = () => ( + <> + + + +
+ 사용자가 제출한 실제 DB 큐만 표시합니다. 해결된 항목은 큐에서 내려 최근 이력으로 분리합니다. +
+ +
+ {ticketsError ? : null} +
+
+ 전체 접수 + {tickets ? countLabel(tickets.summary.total) : "-"} + {tickets?.durable ? "DB 티켓" : "저장소 확인 중"} +
+
+ 미해결 + {tickets ? countLabel(tickets.summary.open_count) : "-"} + open, triaged, in_progress +
+
+ 높은 우선순위 + {tickets ? countLabel(tickets.summary.high_priority_count) : "-"} + 미해결 high, urgent +
+
+ 정체 + {tickets ? countLabel(tickets.summary.stale_count) : "-"} + 24시간 이상 미변경 +
+
+ + {ticketsLoading && !tickets ? ( +
+ +
+ ) : null} + + {!ticketsLoading && tickets && activeTickets.length === 0 ? ( +
+ +
+ ) : null} + + {activeTickets.length > 0 ? ( +
+ {activeTickets.map((ticket) => ( +
+
+
+ {ticketCategoryLabel(ticket.category)} + {ticket.reporter.email} + 갱신 {dateTimeLabel(ticket.updated_at)}
- {roleText(user.role)} +

{ticket.subject}

+

{ticket.body}

+ + {ticket.source_path || "출처 없음"} · 접수 {dateTimeLabel(ticket.created_at)} +
- -
- - - - -
- -
- 활성 세션 {user.active_sessions} - 코호트 {cohortInputValue(user.cohort_ids) || "-"} - 최근 {dateTimeLabel(user.last_seen_at)} -
- -
+ {ticketPriorityLabel(ticket.priority)} + {ticketStatusLabel(ticket.status)} +
- ); - })} - - {usersLoading && !usersData - ? Array.from({ length: 2 }).map((_, index) => ( - - )) - : null} - {!usersLoading && !usersData ? ( -
- 사용자 목록을 불러오지 못했습니다. 새로고침을 눌러 다시 시도하세요. -
- ) : null} - {!usersLoading && usersData && filteredUsers.length > visibleUsers.length ? ( -
- 검색어를 입력하면 나머지 {filteredUsers.length - visibleUsers.length}명을 더 좁혀 볼 수 있습니다. -
- ) : null} - {!usersLoading && usersData && filteredUsers.length === 0 ? ( -
- {hasUserSearch - ? "검색 조건에 맞는 사용자가 없습니다." - : "아직 등록된 사용자가 없습니다."} -
- ) : null} -
+ ))}
+ ) : null} + + + {recentResolvedTickets.length > 0 ? ( +
+
+

최근 해결 이력

+ {countLabel(recentResolvedTickets.length)}건 +
+
+ {recentResolvedTickets.map((ticket) => ( +
+
+
+ {ticketCategoryLabel(ticket.category)} + {ticket.reporter.email} + 해결 {dateTimeLabel(ticket.resolved_at ?? ticket.updated_at)} +
+

{ticket.subject}

+

{ticket.body}

+ + {ticket.source_path || "출처 없음"} · 접수 {dateTimeLabel(ticket.created_at)} + +
+ {ticketPriorityLabel(ticket.priority)} + {ticketStatusLabel(ticket.status)} +
+ 이력 +
+
+ ))}
-
+ ) : null} + + + ); + + return ( + +
+ {section === "overview" ? renderOverview() : null} + {section === "users" ? renderUsers() : null} + {section === "access" ? renderAccess() : null} + {section === "tickets" ? renderTickets() : null} +
); } -const ADMIN_CSS = ` -.ad-root{ - width:min(100%,1180px); - margin:0 auto; - position:relative; - isolation:isolate; - display:flex; - flex-direction:column; - gap:16px; +function InlineError({ message }: { message: string }) { + return ( +
+ + {message} +
+ ); +} + +function EmptyState({ title, body }: { title: string; body: string }) { + return ( +
+ {title} +

{body}

+
+ ); +} + +function TabBar({ + ariaLabel, + items, + value, + onChange, +}: { + ariaLabel: string; + items: Array<[T, string]>; + value: T; + onChange: (value: T) => void; +}) { + return ( +
+ {items.map(([id, label]) => ( + + ))} +
+ ); +} + +function RoleMeter({ label, value, total }: { label: string; value: number; total: number }) { + const pct = total > 0 ? Math.round((value / total) * 100) : 0; + return ( +
+
+ {label} + {countLabel(value)}명 +
+ +
+ ); } -.ad-root::before{ - content:""; - position:absolute; - z-index:-1; - inset:-80px 0 auto auto; - width:min(560px,52vw); - height:420px; - background:var(--asset-warm-elements) center / cover no-repeat; - opacity:.045; - filter:saturate(.75); - pointer-events:none; -} -.ad-head{ - display:flex; - align-items:flex-end; - justify-content:space-between; - gap:var(--sp-4); - flex-wrap:wrap; - padding:0 2px 2px; -} -.ad-head h1{ - margin:6px 0 0; - color:var(--text-strong); - font-size:var(--fs-h2); - line-height:1.28; - letter-spacing:0; -} -.ad-head p{ - margin:6px 0 0; - max-width:680px; - color:var(--text-body); - font-size:var(--fs-xs); - line-height:1.55; -} -.ad-ops{ - display:grid; - grid-template-columns:minmax(320px,.64fr) minmax(0,1fr); - gap:12px; -} -.ad-status{ - display:grid; - grid-template-columns:12px minmax(0,1fr) auto; - align-items:center; - gap:12px; - min-width:0; - padding:14px 16px; - border:1px solid var(--hair); - border-radius:var(--radius); - background: - linear-gradient(180deg,color-mix(in srgb,var(--accent-tint) 42%,var(--bg-surface)),var(--bg-surface)); - box-shadow:var(--shadow-sm); -} -.ad-status__dot{ - width:10px; - height:10px; - border-radius:50%; - background:var(--warn-solid); -} -.ad-status--ok .ad-status__dot{background:var(--pos-solid);} -.ad-status--down .ad-status__dot{background:var(--crit-solid);} -.ad-status b{ - display:block; - color:var(--text-strong); - font-size:var(--fs-body); - line-height:1.35; -} -.ad-status span{ - display:block; - margin-top:3px; - color:var(--text-muted); - font-size:var(--fs-xs); -} -.ad-status time{ - color:var(--text-muted); - font-family:var(--font-num); - font-size:var(--fs-xs); - white-space:nowrap; -} -.ad-error{ - display:flex; - align-items:center; - gap:10px; - padding:12px 14px; - border-radius:var(--radius); - background:var(--crit-tint); - color:var(--crit-text); - font-size:var(--fs-sm); -} -.ad-kpis{ - display:grid; - grid-template-columns:repeat(4,minmax(0,1fr)); - border:1px solid var(--hair); - border-radius:var(--radius); - overflow:hidden; - background:var(--bg-surface); - box-shadow:var(--shadow-sm); -} -.ad-kpi{ - min-width:0; - padding:11px 12px; - display:grid; - gap:4px; -} -.ad-kpi:nth-child(even){border-left:1px solid var(--hair);} -.ad-kpi + .ad-kpi{border-left:1px solid var(--hair);} -.ad-kpi:nth-child(n+3){border-top:0;} -.ad-kpi__lab{ - display:block; - color:var(--text-muted); - font-size:var(--fs-xs); - font-weight:650; -} -.ad-kpi b{ - display:block; - color:var(--text-strong); - font-family:var(--font-num); - font-size:23px; - line-height:1; -} -.ad-kpi small{ - color:var(--text-muted); - font-size:12px; - line-height:1.35; -} -.ad-usage-section{ - padding:14px; - border:1px solid var(--hair); - border-radius:var(--radius); - background:var(--bg-surface); - box-shadow:var(--shadow-sm); -} -.ad-usage-grid{ - display:grid; - grid-template-columns:repeat(4,minmax(0,1fr)); - gap:10px; -} -.ad-usage-kpi{ - min-width:0; - display:grid; - gap:5px; - padding:12px; - border:1px solid var(--hair); - border-radius:var(--radius-sm); - background:var(--bg-surface-2); -} -.ad-usage-kpi span{ - color:var(--text-muted); - font-size:var(--fs-xs); - font-weight:700; -} -.ad-usage-kpi b{ - color:var(--text-strong); - font-family:var(--font-num); - font-size:22px; - line-height:1; - overflow-wrap:anywhere; -} -.ad-usage-kpi small{ - color:var(--text-muted); - font-size:12px; - line-height:1.35; -} -.ad-usage-budget{ - display:flex; - align-items:flex-start; - gap:10px; - min-width:0; - padding:10px 12px; - border:1px solid var(--hair); - border-radius:var(--radius-sm); - background:var(--bg-surface-2); - color:var(--text-muted); -} -.ad-usage-budget svg{ - flex:0 0 auto; - margin-top:1px; -} -.ad-usage-budget div{ - display:grid; - gap:2px; - min-width:0; -} -.ad-usage-budget b{ - color:var(--text-strong); - font-size:var(--fs-xs); - line-height:1.3; -} -.ad-usage-budget span{ - font-size:12px; - line-height:1.4; - overflow-wrap:anywhere; -} -.ad-usage-budget--warn{ - border-color:color-mix(in srgb,var(--warn-solid) 42%,var(--hair)); - background:color-mix(in srgb,var(--warn-tint) 72%,var(--bg-surface)); - color:var(--warn-text); -} -.ad-usage-budget--exceeded{ - border-color:color-mix(in srgb,var(--crit-solid) 42%,var(--hair)); - background:color-mix(in srgb,var(--crit-tint) 72%,var(--bg-surface)); - color:var(--crit-text); -} -.ad-usage-budget--ok{ - border-color:color-mix(in srgb,var(--pos-solid) 36%,var(--hair)); - background:color-mix(in srgb,var(--pos-tint) 72%,var(--bg-surface)); - color:var(--pos-text); -} -.ad-usage-breakdown{ - display:flex; - flex-direction:column; - min-width:0; - border:1px solid var(--hair); - border-radius:var(--radius-sm); - overflow:hidden; - background:var(--bg-surface-2); -} -.ad-usage-breakdown__head, -.ad-usage-row{ - display:grid; - grid-template-columns:minmax(170px,1fr) 72px 110px 90px; - align-items:center; - gap:10px; - padding:9px 12px; -} -.ad-usage-breakdown__head{ - background:var(--bg-app); - color:var(--text-muted); - font-size:var(--fs-xs); - font-weight:800; -} -.ad-usage-row{ - border-top:1px solid var(--hair); - color:var(--text-body); - font-family:var(--font-num); - font-size:var(--fs-sm); -} -.ad-usage-row > span{ - min-width:0; -} -.ad-usage-row > span:first-child{ - display:grid; - gap:2px; - font-family:var(--font-sans); -} -.ad-usage-row b{ - color:var(--text-strong); - font-size:var(--fs-sm); - line-height:1.25; - overflow-wrap:anywhere; -} -.ad-usage-row small{ - color:var(--text-muted); - font-size:12px; - line-height:1.25; - overflow-wrap:anywhere; -} -.ad-section{ - display:flex; - flex-direction:column; - gap:10px; - min-width:0; -} -.ad-section__head{ - display:flex; - align-items:baseline; - justify-content:space-between; - gap:var(--sp-3); -} -.ad-section__head h2{ - margin:0; - color:var(--text-strong); - font-size:var(--fs-body); - font-weight:700; - line-height:1.3; - letter-spacing:0; -} -.ad-section__head span{ - color:var(--text-muted); - font-size:var(--fs-xs); -} -.ad-user-create{ - display:grid; - grid-template-columns:1fr; - align-items:end; - gap:10px; - min-width:0; - padding:12px; - border:1px solid var(--hair); - border-radius:var(--radius); - background:var(--bg-surface); -} -.ad-user-create label{ - min-width:0; - display:flex; - flex-direction:column; - gap:6px; -} -.ad-user-create span{ - color:var(--text-muted); - font-size:var(--fs-xs); - font-weight:650; -} -.ad-user-create input, -.ad-user-create select, -.ad-users-toolbar input, -.ad-user__fields input, -.ad-user__fields select{ - width:100%; - min-width:0; - height:36px; - border:1px solid var(--border-strong); - border-radius:var(--radius-sm); - background:var(--bg-surface-2); - color:var(--text-strong); - padding:0 10px; - font:var(--fs-sm)/1.3 var(--font-sans); -} -.ad-user-create input:focus, -.ad-user-create select:focus, -.ad-users-toolbar input:focus, -.ad-user__fields input:focus, -.ad-user__fields select:focus{ - outline:none; - border-color:var(--border-focus); - box-shadow:0 0 0 3px var(--focus-ring); -} -.ad-user-create .vg-btn{ - width:100%; - height:36px; - justify-content:center; - white-space:nowrap; - padding-inline:12px; -} -.ad-services{ - display:flex; - flex-direction:column; - min-width:0; - border:1px solid var(--hair); - border-radius:var(--radius); - background:var(--bg-surface); - overflow:hidden; - box-shadow:var(--shadow-sm); -} -.ad-service{ - min-width:0; - min-height:0; - display:grid; - grid-template-columns:minmax(170px,.8fr) minmax(0,1.3fr) minmax(170px,.7fr); - align-items:center; - gap:12px; - padding:12px 14px; - border-top:1px solid var(--paper-2); - transition:border-color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out); -} -.ad-service:first-child{border-top:0;} -.ad-service--ok{ - background:color-mix(in srgb,var(--pos-tint) 22%,var(--bg-surface)); -} -.ad-service--degraded{ - background:color-mix(in srgb,var(--warn-tint) 28%,var(--bg-surface)); -} -.ad-service--down{ - background:color-mix(in srgb,var(--crit-tint) 28%,var(--bg-surface)); -} -.ad-service:hover{ - background:var(--bg-surface-2); -} -.ad-service__top{ - display:flex; - align-items:center; - justify-content:space-between; - gap:10px; -} -.ad-service__name{ - min-width:0; - display:flex; - align-items:center; - gap:9px; -} -.ad-service__name b{ - color:var(--text-strong); - font-size:var(--fs-sm); - line-height:1.35; - overflow-wrap:anywhere; -} -.ad-service p{ - margin:0; - color:var(--text-body); - font-size:var(--fs-xs); - line-height:1.45; - overflow-wrap:anywhere; -} -.ad-service__meter{ - display:grid; - grid-template-columns:minmax(0,1fr) auto; - align-items:center; - gap:10px; -} -.ad-service__meter span{ - min-width:0; - color:var(--text-muted); - font-family:var(--font-num); - font-size:var(--fs-xs); - white-space:normal; - overflow-wrap:anywhere; - text-align:right; -} -.ad-service--skeleton{ - min-height:58px; - background:var(--bg-surface-2); - border-color:transparent; -} -.ad-empty{ - padding:var(--sp-5); - color:var(--text-muted); - font-size:var(--fs-sm); - line-height:1.5; - text-align:center; -} -.ad-skel{ - display:block; - height:12px; - border-radius:999px; - background:linear-gradient(90deg,var(--paper-2) 25%,var(--bg-surface) 50%,var(--paper-2) 75%); - background-size:200% 100%; - animation:ad-shimmer 1.4s ease-in-out infinite; -} -.ad-skel--name{width:62%;} -.ad-skel--detail{width:92%;} -.ad-skel--meter{width:72%;} -.ad-skel--action{width:74px;justify-self:end;} -@keyframes ad-shimmer{ - 0%{background-position:200% 0;} - 100%{background-position:-200% 0;} -} -@media (prefers-reduced-motion:reduce){ - .ad-skel{animation:none;} -} -.ad-user-workspace{ - display:grid; - grid-template-columns:minmax(0,1fr) minmax(258px,300px); - align-items:stretch; - gap:14px; - min-width:0; -} -.ad-user-sidecar{ - order:2; - position:sticky; - top:calc(var(--topbar-h) + 16px); - align-self:start; - display:flex; - flex-direction:column; - gap:10px; - min-width:0; -} -.ad-user-listpane{ - order:1; - display:flex; - flex-direction:column; - min-width:0; -} -.ad-users-note{ - padding:10px 12px; - border-radius:var(--radius); - background:var(--info-tint); - color:var(--info-text); - font-size:var(--fs-xs); - line-height:1.5; - border:1px solid color-mix(in srgb,var(--info-solid) 18%,transparent); - overflow-wrap:anywhere; -} -.ad-users-note--warn{ - background:var(--warn-tint); - color:var(--warn-text); - border-color:color-mix(in srgb,var(--warn-solid) 20%,transparent); -} -.ad-users-toolbar{ - display:flex; - align-items:end; - justify-content:space-between; - gap:12px; - margin-bottom:10px; -} -.ad-users-toolbar label{ - width:min(100%,360px); - min-width:0; - display:flex; - flex-direction:column; - gap:6px; -} -.ad-users-toolbar label span{ - color:var(--text-muted); - font-size:var(--fs-xs); - font-weight:650; -} -.ad-users-toolbar > span{ - min-width:0; - color:var(--text-muted); - font-family:var(--font-num); - font-size:var(--fs-xs); - text-align:right; - overflow-wrap:anywhere; -} -.ad-users{ - flex:1 1 auto; - max-height:min(760px,calc(100vh - 116px)); - overflow:auto; - scrollbar-gutter:stable; - display:flex; - flex-direction:column; - min-width:0; - border:1px solid var(--hair); - border-radius:var(--radius); - background:var(--bg-surface); - box-shadow:var(--shadow-sm); -} -.ad-user{ - min-width:0; - display:grid; - grid-template-columns:minmax(190px,.75fr) minmax(420px,1.4fr) minmax(138px,.42fr) max-content; - align-items:center; - gap:10px 12px; - padding:10px 12px; - border-top:1px solid var(--paper-2); - background:var(--bg-surface); -} -.ad-user:first-child{ - border-top:0; -} -.ad-user__top{ - display:flex; - align-items:center; - justify-content:space-between; - gap:10px; - min-width:0; - overflow:hidden; -} -.ad-user__id{ - min-width:0; - display:flex; - align-items:center; - gap:10px; -} -.ad-user__id > span{ - width:34px; - height:34px; - flex:none; - border-radius:50%; - display:grid; - place-items:center; - background:var(--accent-tint); - color:var(--accent-deep); - font-weight:750; -} -.ad-user__id b{ - display:block; - color:var(--text-strong); - font-size:var(--fs-sm); - line-height:1.35; - overflow-wrap:anywhere; -} -.ad-user__id p{ - margin:2px 0 0; - color:var(--text-muted); - font-size:var(--fs-xs); - overflow-wrap:anywhere; -} -.ad-user__fields{ - display:grid; - grid-template-columns:minmax(130px,1.1fr) 96px minmax(118px,.95fr) minmax(110px,.85fr); - gap:8px; - min-width:0; -} -.ad-user__fields label{ - min-width:0; - display:flex; - flex-direction:column; - gap:6px; -} -.ad-user__fields span{ - display:none; - color:var(--text-muted); - font-size:var(--fs-xs); - font-weight:650; -} -.ad-user__fields input, -.ad-user__fields select{ - height:32px; - font-size:12.5px; - padding-inline:9px; -} -.ad-user__meta{ - display:grid; - gap:4px; - min-width:0; - color:var(--text-muted); - font-family:var(--font-num); - font-size:var(--fs-xs); -} -.ad-user__meta span{ - min-width:0; - overflow-wrap:anywhere; -} -.ad-user__actions{ - display:flex; - flex-wrap:nowrap; - gap:8px; - justify-content:flex-end; - justify-self:end; - max-width:100%; - min-width:0; -} -.ad-user__actions .vg-btn{ - min-width:74px; - padding-inline:10px; -} -.ad-user--skeleton{ - min-height:70px; - background:var(--bg-surface-2); - border-color:transparent; -} -.ad-users-empty{ - padding:var(--sp-5); - background:var(--bg-surface); - color:var(--text-muted); - font-size:var(--fs-sm); - text-align:center; -} -@media (max-width:1179px){ - .ad-user-workspace{ - grid-template-columns:1fr; - } - .ad-user-sidecar{ - order:0; - position:static; - } - .ad-user-listpane{order:1;} - .ad-user-create{grid-template-columns:repeat(2,minmax(0,1fr));} - .ad-user{ - grid-template-columns:minmax(190px,.8fr) minmax(0,1.2fr) max-content; - } - .ad-user__fields{ - grid-template-columns:minmax(0,1fr) 104px; - } - .ad-user__fields span{ - display:block; - } - .ad-user__meta{ - grid-column:1 / -1; - display:flex; - flex-wrap:wrap; - gap:6px 14px; - } -} -@media (max-width:980px){ - .ad-ops{ - grid-template-columns:1fr; - } - .ad-kpis{ - grid-template-columns:repeat(4,minmax(0,1fr)); - } - .ad-kpi:nth-child(even), - .ad-kpi + .ad-kpi{border-left:1px solid var(--hair);} - .ad-kpi:nth-child(n+3){border-top:0;} - .ad-usage-grid{ - grid-template-columns:repeat(2,minmax(0,1fr)); - } - .ad-service{ - grid-template-columns:minmax(160px,.85fr) minmax(0,1fr); - } - .ad-service__meter{ - grid-column:1 / -1; - } - .ad-user{ - grid-template-columns:1fr; - align-items:start; - } - .ad-user__actions{ - justify-content:flex-start; - } -} -@media (max-width:700px){ - .ad-root::before{display:none;} - .ad-head{ - align-items:flex-start; - flex-direction:column; - } - .ad-head .vg-btn{ - width:100%; - } - .ad-kpis{ - grid-template-columns:repeat(2,minmax(0,1fr)); - } - .ad-kpi:nth-child(even){border-left:1px solid var(--hair);} - .ad-kpi:nth-child(odd){border-left:0;} - .ad-kpi:nth-child(n+3){border-top:1px solid var(--hair);} - .ad-usage-breakdown__head{ - display:none; - } - .ad-usage-row{ - grid-template-columns:minmax(0,1fr) auto; - gap:8px 12px; - } - .ad-usage-row > span:first-child{ - grid-row:1 / span 3; - } - .ad-service{ - grid-template-columns:1fr; - } - .ad-service__meter{ - grid-column:auto; - } - .ad-user-create{ - grid-template-columns:1fr; - } - .ad-users-toolbar{ - align-items:stretch; - flex-direction:column; - } - .ad-users-toolbar label{ - width:100%; - } - .ad-users-toolbar > span{text-align:left;} -} -@media (max-width:520px){ - .ad-status{grid-template-columns:12px minmax(0,1fr);} - .ad-status time{grid-column:2;} - .ad-kpis{grid-template-columns:1fr;} - .ad-kpi, - .ad-kpi:nth-child(even), - .ad-kpi + .ad-kpi{ - border-left:0; - } - .ad-kpi + .ad-kpi{border-top:1px solid var(--hair);} - .ad-usage-grid{grid-template-columns:1fr;} - .ad-usage-row{ - grid-template-columns:1fr; - } - .ad-usage-row > span:first-child{ - grid-row:auto; - } - .ad-service__meter{grid-template-columns:1fr;} - .ad-service__meter span{text-align:left;} - .ad-user__top{align-items:stretch;flex-direction:column;} - .ad-user__fields{grid-template-columns:1fr;} - .ad-user__actions{ - display:grid; - grid-template-columns:repeat(2,minmax(0,1fr)); - width:100%; - } - .ad-user__actions .vg-btn{ - width:100%; - justify-content:center; - } -} -`; diff --git a/apps/web/src/pages/Login.tsx b/apps/web/src/pages/Login.tsx index 5a53b3d..c9b872f 100644 --- a/apps/web/src/pages/Login.tsx +++ b/apps/web/src/pages/Login.tsx @@ -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() { -

실제 계정으로 들어가고, 실제 회기만 남깁니다.

+

실제 계정으로 들어가고,
실제 회기만 남깁니다.

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; } diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index 1945acd..13213fa 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -281,15 +281,16 @@

project pulse -

지금 위치: Phase 0 마감 직전 → Phase 1 진입 대기.
위기게이트 109교수자 성장 UI는 1차 구현 완료.

-

코어(저항·반응 조절, 상태머신, RAG, 이론모드, 가드레일, 평가 JSON Schema)는 잘 정렬됐고 회귀도 녹색이다. 막힌 건 두 종류 — 내가 실증/결정해야 풀리는 외부 의존(아래 "내 차례")과, 남은 원천문서·콘텐츠·운영 갭이다. 이 판은 그 둘을 분리해서 보여준다.

+

지금 위치: Phase 0 마감 직전 → Phase 1 진입 대기.
학습자 개인화 대시보드교수자 검토 상태는 1차 구현·검증 완료.

+

코어(저항·반응 조절, 상태머신, RAG, 이론모드, 가드레일, 평가 JSON Schema)는 잘 정렬됐고, 이번 점검에서 학습자 홈·교수자 리뷰·공개/Tailnet 런타임 스모크도 녹색이다. 막힌 건 두 종류 — 실제 계정·외부 DNS·기관 결정이 있어야 풀리는 게이트와, 임상 루브릭·콘텐츠·운영 증거처럼 아직 남은 제품 갭이다. 이 판은 그 둘을 분리해서 보여준다.

- Full Playwright 72 통과 - Backend pytest 130 통과 + Full E2E 113 통과 + Backend focused 12 통과 Layout 시각게이트 7/7 accept - Pages prod 12da49f3 + Tailnet alpaca-home OK Public API prod OK + Compose env BLOCK 원천문서 갭 critical 3 Decision P0 조건부
@@ -454,6 +455,23 @@

6차 적용(2026-06-27): 설정 화면은 글로벌 셸을 숨기고 설정 전용 좌측 레일을 주 내비로 승격했다. 계정, AI 운영, 테마, 음성, 알림 카드를 시안의 차분한 설정 워크스페이스 흐름에 맞춰 단일 컬럼으로 재배치했고, 모바일은 상단 고정 섹션 탭으로 모든 항목이 잘리지 않게 정리했다.

7차 적용(2026-06-27): 로그인 화면은 별도 생성한 래스터 상담실 배경(login-counseling-room.png)을 왼쪽 브랜드 패널에 적용해 시안의 실제 상담실 분위기를 반영했다. 학습자 홈은 모바일 grid 행 계산 때문에 생기던 리스트-상세 간 큰 공백을 제거하고, 회기 준비 보드가 한 흐름으로 이어지게 정리했다.

8차 적용(2026-06-27): 다크 테마가 라이트 primitive 토큰과 역할 accent에 덮여 흰 섹션이 섞이던 문제를 수정했다. 앱 부트 시 테마를 먼저 적용하고, 다크 모드에서 paper/surface/hair/ink primitive까지 스왑한다. 상담 세션 진행 화면은 테마 토글과 무관하게 어두운 콘솔 패널로 고정했고, 드래그형 종료 제스처는 명시적 회기 종료 버튼 + 확인 다이얼로그로 교체했다. 회기 리뷰도 동일한 다크 카드/축어록/워크시트 재질로 보정했다.

+

9차 적용(2026-06-27): 관리자 콘솔 IA를 /admin 운영 홈과 /admin/users 사용자 관리, /admin/access 접근 권한, /admin/tickets 운영 티켓 메뉴로 분리했다. 운영 홈은 실제 API가 있는 교육 진행·온라인 사용자·헬스·AI 비용·서비스 리소스를 우선 표시하고, 티켓/업타임 이력은 app.support_ticket·app.admin_health_event 기반 1차 저장 모델로 연결했다. 세분 그룹 권한 enforcement는 아직 정책 화면이며 가짜 권한 효과를 표시하지 않는다.

+

17차 적용(2026-06-28): 운영 홈의 티켓 요약은 resolved/closed 이력을 진행 중 위험으로 세지 않는다. high_priority_count와 미니 티켓 목록은 미해결 티켓 기준이며, E2E 운영 티켓 테스트는 제목·본문에 테스트임을 명시하고 실패 시에도 cleanup patch로 해결 처리한다.

+

19차 적용(2026-06-28): /admin/tickets는 현재 범위를 미해결 큐 목록과 상태 변경으로 좁혔다. 정적 분류/인사이트 탭은 제거하고, 카드에 카테고리·신고자·경로·접수/갱신 시각을 표시한다. 해결된 항목은 최근 해결 이력으로 내려간다. 필터·카테고리 큐·Claude Recipe headless 자동 처리·이슈 등록은 백로그 운영 티켓 자동 분류·처리 2차로 남겼다.

+

20차 적용(2026-06-28): 신규 Google/SAML 사용자는 account_status=pending으로 시작하고 승인 전에는 /pending 안내 화면만 본다. yunchan@twentyoz.kr는 슈퍼 관리자 allowlist로 admin+approved를 받으며, /admin/users는 가입 승인 탭에서 pending 계정을 승인 또는 보류 처리한다.

+

21차 적용(2026-06-28): 관리자 페이지 진입권을 기본 역할과 분리해 app_user.admin_access로 저장한다. AUTH_SUPER_ADMIN_EMAILS 기본값은 yunchan@twentyoz.kr, hoonjungkoo@hs.ac.kr이며, 슈퍼 관리자는 학습자·교수자·관리자 공간 전환과 관리자 권한 부여/회수를 할 수 있다. 학생·교수 계정도 admin_access=true면 우측 상단 관리자 진입이 노출된다. 구성 슈퍼 관리자의 권한 회수와 계정 비활성화는 차단한다.

+

25차 적용(2026-06-28): /admin/users에서 허용 도메인 밖 이메일도 정확한 계정 단위로 강제 등록할 수 있다. Google/SAML/dev-login은 미리 등록된 이메일만 도메인 게이트 예외로 통과시키고, provider 로그인 시 기존 관리 row의 역할·코호트·승인 상태를 이어받는다. 미등록 외부 도메인 로그인은 계속 차단한다.

+

10차 적용(2026-06-27): 학습자 영역을 /learn 대시보드, /learn/practice 연습 대상, /learn/history 기록으로 분리했다. 홈은 최근/진행 회기를 기준으로 Live2D 아바타·감정 힌트·마지막 내담자 반응·학습자 마지막 발화·AI 코치 문장을 보여주는 세션 리캡 중심 대시보드로 바꾸고, 새 회기 시작은 학습 탭으로 낮췄다.

+

11차 적용(2026-06-27): 현재 /learn 화면을 다시 캡처한 뒤 UX 원칙(사용자 필요, progressive disclosure, 정보 향, adaptive layout)을 기준으로 이미지 생성 시안 3장을 만들었다. UI 지침에는 11-learner-dashboard-redesign-concept-b.png, 12-learner-dashboard-redesign-concept-b-alt.png만 채택하고, 상담실 배경으로 나온 10-learner-dashboard-redesign-concept-a.png는 폐기했다. 구현은 회기 리캡과 AI 코치/이어하기 CTA를 홈 첫 블록으로 묶고, 독립 다음 행동 카드는 제거했다.

+

16차 적용(2026-06-28): 생성 시안과 더 맞추기 위해 /learn 하단을 리뷰 대기, 다음 연습 추천, 최근 기록 3분할 카드로 재배치했다. AI 코치 패널에서는 진행/리뷰 카운트와 보조 링크를 제거하고, 코칭 문장·3개 체크 힌트·단일 이어하기 CTA만 남겼다. 최신 검증 캡처는 docs/design-verification/learner-home-redesign/final-*.png다.

+

18차 적용(2026-06-28): /learn/history는 표형 기존 회기 목록을 제거하고 전체, 진행 중, 리뷰 필요, 보관됨 상태 필터와 검색, 카드형 회기 기록 작업대로 재구성했다. 설계 지침은 docs/design-concepts/prompts/learner-history-archive-redesign-2026-06-28.md, 검증 캡처는 docs/design-verification/learner-history-redesign/history-*.png다.

+

22차 적용(2026-06-28): 회기 아카이브를 실제 저장 상태로 연결했다. 학습자 종료 회기는 POST /sessions/{id}/archive/restore로 보관·복원하고, app.session_archive_state는 삭제가 아니라 학습자별 보기 상태만 저장한다. 회기·턴·리뷰·공유 링크·연구/감사 증거는 유지하며, 보관된 회기는 /learn/history보관됨 필터와 dashboard archived_sessions에 반영되고 리뷰 대기 행동 큐에서는 제외된다.

+

24차 적용(2026-06-28): 운영 콘솔 /admin/usage의 DB 집계 쿼리에서 ORDER BY tokens_in + tokens_out가 PostgreSQL 원본 컬럼 참조로 해석되어 prod에서 503 fail-closed 되던 문제를 수정했다. provider/model별 정렬은 SUM(tokens_in)+SUM(tokens_out) 집계식 기준으로 바꿨고, public API 프로세스의 실제 HTTP smoke는 source=database, durable=true로 200을 반환한다.

+

23차 적용(2026-06-28): app.persona_voice_map을 실제 /voice/ws TTS voice 선택에 연결했다. 명시 query preset이 최우선이고, 없으면 세션의 persona_id/persona_version 또는 dev persona catalog의 voice map을 읽으며, unsupported provider는 기존 persona-code fallback으로 fail-safe 처리한다. seed materializer는 기본 OpenAI voice map row를 ON CONFLICT DO NOTHING으로 생성하고, dev 런타임 스키마 보강은 기존 DB의 persona_voice_map 누락도 함께 복구한다.

+

12차 적용(2026-06-27): 회기 리뷰 데스크톱 레이아웃은 sticky 요약 레일을 유지하되 트랜스크립트가 같은 컬럼을 공유하지 않도록 재배치했다. 왼쪽 요약 문장은 보조 브리핑 밀도로 낮추고, 축어록은 중앙 근거 작업대에 고정해 스크롤 중 레일이 발화 내용을 가리지 않게 했다.

+

13차 적용(2026-06-27): 회기 리뷰의 적절한 개입 등 평가 콜아웃은 제한 markdown을 기본 렌더링한다. effective_opennesseffective openness(유효 개방도)로 학술 용어화하고, 상담자 질문 발화는 ReviewNote.quote 인용 블록으로 분리해 카드 내부에서 시각화한다.

+

14차 적용(2026-06-27): 활성 상담 세션은 시작 직후 실제 session_id URL로 고정해 새로고침 후에도 같은 회기 상세를 복원한다. 전역 topbar는 몰입형 화면에서 계속 숨기되, 내부 세션바에 기록으로/학습 홈 이동을 추가했다. 종료된 세션 상세도 곧바로 리뷰로 밀어내지 않고 읽기 전용 기록 화면에 머물며, 리뷰 이동은 명시 버튼으로만 수행한다.

+

15차 적용(2026-06-28): 학습자·교수자·운영·페르소나 스튜디오 화면을 같은 작업형 폭 정책으로 정렬했다. 교수 콘솔은 검토 대기·위기 알림·페르소나 검수·저작실 순서가 DOM과 화면 모두 일치하고, 페르소나 스튜디오는 SSOT 첨부/RAG 생성/항목 편집/검증 큐가 한 작업면에서 이어지는 구조로 유지한다. 적용 리포트와 대표 캡처는 docs/ops/layout-research-2026-06-28/dashboard-layout-alignment-report.md에 남겼다.

래스터만 사용이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.
기능 우선메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.
@@ -467,11 +485,11 @@ 로그인/진입Google OAuth, 허용 도메인 안내, 로컬 역할 선택, 교육용 비치료 고지신뢰·보안이 먼저 보이는 조용한 진입 화면 - 학습자 홈내담자 선택, 주호소·난도·접근·음성 정보, 새 회기 시작, 기존 회기 이어가기오늘 시작할 연습이 명확한 상담 연습 작업대 + 학습자 홈학습 요약, 진행/리뷰 가능 회기, 마지막 세션 리캡, Live2D 감정 힌트, AI 코치, 학습·기록 드릴다운오늘 이어갈 회기와 학습 상태가 먼저 보이는 개인 대시보드 상담 세션회기 단계, 내담자 컨텍스트, 아바타, 음성 오브, 실시간 자막, 텍스트 폴백, 안전 점검화면 중심은 내담자, 피드백은 조용한 신호 회기 리뷰요약, 감정 밸런스, 회기 흐름, 축어록, 기법 태그, 개선점, 오디오/PDF점수 폭격이 아니라 근거 기반 회고 교수 콘솔리뷰 대기 큐, 코호트 현황, 페르소나 검수, 종료 회기 목록awareness보다 triage에 가까운 교수자 작업대 - 관리자 콘솔서비스 상태, 엔진·DB·음성·리뷰 리소스, 사용자 등록·수정·비활성화시스템 건강성과 권한 관리가 한눈에 보이는 운영 화면 + 관리자 콘솔운영 홈: 교육 진행·온라인 사용자·헬스 샘플 이력·AI 비용·운영 티켓 요약. 하위 메뉴: 사용자 관리, 접근 권한, 운영 티켓 처리 큐상태 관측과 관리 작업을 분리한 실제 운영 콘솔 설정계정, 관리자 AI 운영 설정, 테마, 음성 프리셋, 속도, 알림역할별 설정만 보이는 조용한 환경 관리 이미지 요소종이 질감, 상담실 책상, 노트, 식물, 부드러운 빛, 패턴, 아바타 분위기UI 뒤에서만 느껴지는 따뜻함과 반구상 내담자 톤 @@ -486,6 +504,14 @@ 학습자 홈 PC 태블릿 모바일 반응형 디자인 시안
학습자 홈내담자 선택, 회기 시작, 이전 회기 이어가기, 리뷰 가능 상태를 작업대처럼 배치.02-learner-home-responsive.png
+
+ 학습자 대시보드 회기 리캡 중심 재설계 시안 +
학습자 대시보드 재설계회기 리캡, AI 코치, 이어하기 CTA를 첫 화면의 단일 결정 영역으로 묶음.11-learner-dashboard-redesign-concept-b.png
+
+
+ 학습자 대시보드 대안 재설계 시안 +
학습자 대시보드 대안마지막 내담자 반응과 내 마지막 반응을 더 강하게 드러낸 대안 레이아웃.12-learner-dashboard-redesign-concept-b-alt.png
+
라이브 상담 세션 PC 태블릿 모바일 반응형 디자인 시안
라이브 상담 세션내담자 무대, 자막, 입력, 라이브 신호, 안전 상태를 몰입형 훈련 화면으로 구성.03-session-responsive.png
@@ -500,7 +526,7 @@
관리자 콘솔 PC 태블릿 모바일 반응형 디자인 시안 -
관리자 콘솔서비스 상태와 사용자 권한 관리를 운영자가 바로 판단할 수 있는 밀도로 정리.06-admin-responsive.png
+
관리자 콘솔운영 홈은 서비스 상태 관측에 집중하고 사용자·권한·티켓은 별도 메뉴로 분리. 티켓과 헬스 샘플은 DB 기반 1차 모델을 사용.06-admin-responsive.png
설정 화면 PC 태블릿 모바일 반응형 디자인 시안 @@ -554,15 +580,23 @@
-
현재

TeacherDashboardResponse.learner_growth를 추가해 턴별 fast-loop 평가의 appropriateness, rapport_signal, 기법 태그를 학습자별로 집계한다. 교수 콘솔은 학습자별 성장 카드, 회기별 막대 추이, 평균 라포, 변화폭, 최근 회기 점검 포인트를 표시한다. 평가가 없는 회기는 "평가 부족"으로 남겨 가짜 곡선을 만들지 않는다.

검증

pytest app/test_teacher_dashboard.py app/test_session_turn_persistence.py app/test_voice_ws.py -q 21 passed, pytest app/ -q 119 passed, npm run check:api-types, npm run typecheck passed.

잔여

자기효능감·기술숙련도·수련만족도 사전사후(H1)와 교수자 코멘트/검수 워크플로 확장은 별도 평가설계·임상팀 문항 확정이 필요하다.

+
현재

TeacherDashboardResponse.learner_growth를 추가해 턴별 fast-loop 평가의 appropriateness, rapport_signal, 기법 태그를 학습자별로 집계한다. 교수 콘솔은 학습자별 성장 카드, 회기별 막대 추이, 평균 라포, 변화폭, 최근 회기 점검 포인트를 표시한다. 평가가 없는 회기는 "평가 부족"으로 남겨 가짜 곡선을 만들지 않는다. 종료 회기 리뷰 대기 행은 /teach/session/{id}/review로 진입해 같은 회기 리뷰를 교수자 읽기 전용으로 열고, 워크시트는 학습자 제출물 수정 없이 검토 전용으로 표시한다.

검증

pytest app/test_teacher_dashboard.py app/test_session_turn_persistence.py app/test_voice_ws.py -q 21 passed, pytest app/test_rbac_idor.py app/test_teacher_dashboard.py -q 9 passed, npm run check:api-types, npm run typecheck, npm run build passed. npx playwright test e2e/teacher.spec.ts --project=chromium-single-run --workers=1 4 passed.

잔여

자기효능감·기술숙련도·수련만족도 사전사후(H1)와 교수자 코멘트/검수 워크플로 확장은 별도 평가설계·임상팀 문항 확정이 필요하다.

+
+
+ +
현재

교수자/관리자는 /sessions/{id}/review를 읽을 수 있고, 학습자 워크시트 저장은 learner 전용으로 유지한다. 교수자 검토 상태는 app.session_review_statusreview_status, review_note, reviewed_at로 저장되며, teacher dashboard 응답과 SessionReview가 이 값을 표시한다. PUT /teacher/sessions/{session_id}/review-status는 교수자 메모 저장과 검토 완료 처리를 담당하고, 검토 완료된 회기는 pending queue에서 제외한다.

검증

pytest app/test_teacher_dashboard.py app/test_rbac_idor.py app/test_learner_dashboard.py -q 12 passed, npm run check:api-types, npm run typecheck, npm run build, teacher.spec.ts 4 passed. 교수 콘솔 검토 큐 노출과 학습자 리뷰의 교수자 전용 빈 grid 제거는 E2E/시각 게이트로 재검증했다.

잔여

임상 루브릭·최종 검수 기준은 외부 정의 전까지 완료 처리하지 않는다.

+
+
+ +
현재

교수자 성장 집계와 중복되던 점수·라포·기법 계산을 app.services.session_metrics로 공용화했다. GET /sessions/dashboard는 학습자 본인 세션만 집계해 overview, growth, persona_progress, achievements, recent_feedback를 반환하고, LearnerHome은 누적 회기·리뷰 대기·최근 평가·라포 흐름·페르소나별 진행·최근 피드백·마지막 세션 리캡을 실제 서버 데이터로 보여준다. 성취는 공식 수료/등급이 아니라 실제 연습 milestone만 사용한다.

검증

pytest app/test_learner_dashboard.py app/test_teacher_dashboard.py app/test_rbac_idor.py -q 12 passed, npm run check:api-types, npm run typecheck, npm run build, learner.spec.ts 6 passed, session-review.spec.ts 2 passed, layout-visual-gate.spec.ts 7 passed, session-layout.spec.ts 4 passed.

잔여

UX 감사 기준으로 2차에서는 카드 격자 비중을 줄이고 게이지·small multiples·테이블 중심으로 정리해야 한다. 모바일에서 임상 요약을 단순 숨김 처리하는 부분도 접힘/요약으로 바꾸는 게 맞다.

현재

① PDF: 고성능(Claude·GPT=내담자발화·실시간평가)/저비용 한국어(Solar=단순응답·사후요약) 2단계 + 캐싱 90%절감. 현재 client AI는 claude_cli 기본 라우팅, fast/deep evaluator는 EVALUATOR_FAST_MODEL/EVALUATOR_DEEP_MODEL 설정 시 모델 override를 gateway에 전달한다. 벤더중립 인터페이스와 L0~L2 cache hint/session reuse는 있음. ② PDF는 Node.js 우선(팀 보유기술·5개월 일정)/Spring 대안. 현재 Python/FastAPI — 재작성은 일정·인력 리스크.

권고

2-tier는 evaluator 모델 override 이후 client AI/요약 모델 정책과 semantic cache로 점진 도입. 백엔드 언어는 소유자 결정(팀 역량·유지보수 vs 재작성 비용).

- -
현재

페르소나 격리(캐릭터 붕괴 버그)·RAG 비차단(세션생성 블로킹 회귀)·역린/misconduct·init_state 파라미터객체·데드코드(LogHook·tier·RMS)·OAuth state 복구·X1 dataset export dry-run — 백엔드 122 + E2E 42(데스크27·모바일11·아바타4) + 시각게이트 7/7 통과. 스택 라이브(web·api·gateway·voice·RAG).

다음

남은 원천문서 갭과 임상 콘텐츠 적재를 순서대로 닫는다.

+ +
현재

이번 점검 범위인 SEO/GEO 공유 카드, 온보딩 반영 E2E, 학습자 홈, 회기 시작/종료, 리뷰 공유 버튼, 음성 성공 경로, 레이아웃 회귀는 npm run e2e 113 passed + npm run typecheck/npm run build로 닫았다. 공개 API와 Tailnet 스모크도 녹색이고, 패키징 점검에서 더미 운영 env 기준 Docker compose fresh smoke도 통과했다. 과거 focused suite와 Phase 3 evidence는 별도 행의 과거 증거로 유지하되, 이번 완료 증거와 섞어 과장하지 않는다.

다음

남은 원천문서 갭, 임상 루브릭/최종 평가설계, public Google OAuth 실제 /turn, 배포지 운영 비밀값 주입을 순서대로 닫는다.

@@ -587,17 +621,17 @@
현재

doc4가 20명 실험/통제군·단회기 50분·3척도 pre-post를 못박았으나 코드 0건. Phase3 KPI도 report shape만, 계산 코드 0줄. (κ/ICC·환각률은 doc4 미명시 — 평가설계 문서 확정 필요.)

권고

3척도 pre-post 폼·실험/통제군 배정·자동누적 대시보드·추이 시각화·검정 계산 코드. 평가설계는 소유자 결정.

-
- -
현재

텍스트 /turn·/stream·음성 경로에서 fast-loop 턴 평가를 TurnRecord.evaluation에만 남기지 않고 app.feedback_scores, app.turn_technique, app.turn_client_state, app.supervisor_comment, app.alternative_utterance로 정규화 적재 후 리뷰 조회에서 hydrate한다. 원시 평가 테이블은 evaluator/admin RLS로 제한하고 서버가 evaluator 컨텍스트로 적재한다. app.turns insert도 RETURNING id 확인으로 0-row silent success를 차단. 검증: pytest app/test_evaluation_persistence.py -q 6 passed, pytest app/test_session_turn_persistence.py app/test_evaluation_persistence.py -q 21 passed, pytest app/ 119 passed + pytest engine_gateway/ 7 passed.

잔여

더 큰 finalize_turn/SessionRepository 경계 · few-shot 골든셋 기본 OFF · 회기리뷰 2열 UI(축어록 타임라인+비언어) 프론트 강화 · 원천 축어록 골든셋 적재(임상팀).

+
+ +
현재

텍스트 /turn·/stream·음성 경로에서 fast-loop 턴 평가를 app.feedback_scores, app.turn_technique, app.turn_client_state, app.supervisor_comment, app.alternative_utterance로 정규화 적재 후 리뷰 조회에서 hydrate한다. 이번 패스에서 app/services/live_coach.py, data/kb/live_coaching_workbook_0615.json, data/kb/live_coaching_sources/*.json, POST /sessions/{id}/live-coach, GET /sessions/{id}/live-coach, POST /kb/live-coach/source-packs/sync, app.live_coach_events를 추가했다. 세션 UI는 코칭 모드에서 AI 코치 아바타 말풍선으로 즉시 개입하고, "근거 보기" 모달과 학습자 발화 우측 C 마커를 통해 받은 코칭 이력을 채팅 위 스크롤 오버레이로 보여준다. 저장 payload는 PII 마스킹 excerpt + 코칭 구조화 JSON이며, 공식 자료는 허가된 source pack의 version/citation/summary로 넣고 장문 원문·공식 문항을 재현하지 않는다. 같은 source pack은 관리자 sync로 evaluator 전용 RAG에 증분 색인한다.

검증

python -m compileall app -q, pytest app/test_live_coach_sources.py app/test_orchestrator_masking.py app/test_session_turn_persistence.py -q 27 passed, npm run generate:api-types, npm run check:api-types, npm run typecheck, npm run build, npm run e2e -- e2e/layout-visual-gate.spec.ts e2e/session-layout.spec.ts 15 passed. 로컬 DB smoke: source pack sync 4 sources/15 chunks embedded, 재호출 skipped_unchanged=4, official_suicide_risk_guidelines source filter 검색 성공.

잔여

임상팀 골든셋·라이브 코칭 루브릭 검수, 회기리뷰 2열 UI 고도화, 공식 지침/논문 source pack 추가와 버전 운영정책 보강이 남아 있다.

- -
현재

POST/GET/PUT /personas/drafts를 추가해 teacher/admin이 페르소나 카드를 draft로 생성·조회·편집하고 review 상태로 올릴 수 있다. 교수 콘솔에는 JSON 초안 패널과 검수 큐의 편집 불러오기를 연결했다. 승인된 카드만 학습자 catalog에 노출되는 기존 경계는 유지. 이번 2차에서는 data/personas/P4.json~P7.jsonPersonaCard로 읽어 materialize_seed_personas()와 seed fallback catalog에 포함했다.

검증

python -m pytest app/test_persona_review.py -q 22 passed. 로컬 dev DB materialize_seed_personas() 결과 approved_codes=P1,P2,P3,P4,P5,P6,P7, 인증된 로컬 GET /personasP1..P7 반환.

잔여

JSON 대신 항목형 저작 UI, 루브릭·이론 콘텐츠 외부화, 임상팀 최종 검수/서면 evidence는 후속. 임상팀이 제공하지 않은 페르소나 내용을 임의 생성하지 않는다.

+ +
현재

teacher/admin 전용 /teach/personas를 3열 스튜디오로 추가했다. 좌측은 저작 흐름·검수 큐·공개 카탈로그, 중앙은 RAG 자료 등록과 개요/임상/저항/회기/말투/안전/프롬프트 항목형 편집 탭, 우측은 검증·생성 근거·승인/반려 결정이다. 교수 콘솔은 진입점과 triage만 맡고 저작 본체는 스튜디오가 맡는다. 2026-06-27 UI 정리에서 중복 네이티브 파일 입력을 커스텀 SSOT 첨부 드롭존으로 교체하고, 저작 스테퍼·탭별 작성 가이드·검증 dot 정렬을 보강했다. 레이아웃 근거와 생성 시안은 docs/ops/layout-research-2026-06-27/persona-dashboard-layout-guideline.md에 묶었다. POST /personas/sources는 첨부/붙여넣기 자료를 PII 마스킹 후 kb.source/document/chunk에 evaluator 전용(visible_to=['evaluator'], sensitivity=2) 근거 문서로 등록한다. POST /personas/drafts/generatesource_id 기반 RAG 검색 결과만 엔진에 넘기고, draft source_provenance와 응답에 source id·doc/content hash·chunk id를 남긴다. 승인된 카드만 학습자 catalog에 노출되는 경계는 유지하되, P1~P7 시스템 페르소나는 DB 저작 카탈로그의 초기 부트스트랩으로 승격했다. 교수자는 공개 목록의 수정으로 승인본을 같은 persona_id의 다음 버전 draft로 복제해 편집하고, 삭제는 기존 회기 FK를 지키기 위해 같은 code family를 archived로 보관 처리한다. 기동 materializer는 누락분만 insert하고 기존 편집/archived row를 덮어쓰거나 되살리지 않는다.

검증

python -m pytest app/test_persona_review.py -q 31 passed, npm run generate:api-types, npm run check:api-types, npm run typecheck, npm run build passed. PersonaStudio 공개본 수정/삭제 E2E: npx playwright test e2e/teacher.spec.ts --project=chromium-single-run --workers=1 6 passed, layout-visual-gate.spec.ts chromium-single-run 8 passed, session-layout.spec.ts desktop/mobile 8 passed.

잔여

원문 격리본(sensitivity=3) 별도 보관, source/version 전용 마이그레이션 runner, prompt-bundle version pinning, 임상팀 최종 검수/서면 evidence, 루브릭·이론 콘텐츠 외부화는 후속이다. 임상팀이 제공하지 않은 페르소나 내용을 최종 승인본처럼 확정하지 않는다.

- -
현재

정규식 폴백에 한국어 고위험 패턴 추가: 구체 날짜(2001.4.18→[DATE])·금액(1200원→[MONEY])·행정구역 주소(서울시 강남구 역삼동→[ADDR]). 상담 생성(generate/stream)·fast/deep 평가의 외부 LLM 호출 직후 audit.llm_call_log에 provider/model/token/cost/inference_geo/latency만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 /turn smoke에서 audit.llm_call_log가 8→11로 3행 증가했다. 이번 패스에서 app_user.consent_at 기반 learner 전용 동의 수락/철회 API와 회기 시작·voice dev persona 시작 하드게이트를 추가했고, 세션 시작 전 프론트 동의 UI와 E2E seed를 연결했다. 검증: 동의/세션/음성 회귀 64 passed, 전체 API 최신 130 passed, npm run check:api-types, npm run typecheck, npm run build.

잔여

이름/기관명은 NER 필요(Presidio ko 모델·spaCy ko, 무거움 — TODO 명시). 미성년/guardian 및 법무 검토가 필요한 서명 동의서·개인정보 고지 evidence는 외부 게이트다. 공개 Google OAuth 실제 /turn proof는 별도 운영 게이트.

+ +
현재

정규식 폴백에 한국어 고위험 패턴 추가: 구체 날짜(2001.4.18→[DATE])·금액(1200원→[MONEY])·행정구역 주소(서울시 강남구 역삼동→[ADDR]). 상담 생성(generate/stream)·fast/deep 평가의 외부 LLM 호출 직후 audit.llm_call_log에 provider/model/token/cost/inference_geo/latency만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 /turn smoke에서 audit.llm_call_log가 8→11로 3행 증가했다. 이번 패스에서 app_user에 이름·소속·학과·학년/직위·연락처·주소/수령지·닉네임·자기소개·아바타 URL·약관/개인정보 동의 시각·버전을 추가했다. 로그인 후 onboarding_completed_at이 없으면 전역 OnboardingGate/onboarding 외 URL(/learn, /settings, /admin, /dev/avatar-preview, /login 포함)을 모두 온보딩으로 돌리고, 온보딩 중에는 공용 셸 메뉴 없이 가입 직후 사용자 정보 입력 폼만 보여준다. learner 회기 시작도 온보딩 완료 전에는 막는다. /users/me/avatar는 PNG/JPG/WebP MIME·시그니처·3MB 제한 후 파일을 USER_UPLOAD_DIR/profile-avatars에 저장하고 URL만 보관한다. 약관·개인정보 처리방침 초안은 /users/legal-docs에서 제공한다. 관리자/교수자 권한은 온보딩에서 받지 않고 allowlist/관리자 사용자 관리로만 부여한다. 검증: pytest app/ -q 145 passed, pytest engine_gateway/ -q 9 passed, npm run check:api-types, npm run typecheck, npm run build, auth E2E 7 passed, 최종 PLAYWRIGHT_PORT=5174 npm run e2e 113 passed.

잔여

이름/기관명은 NER 필요(Presidio ko 모델·spaCy ko, 무거움 — TODO 명시). 미성년/guardian 및 법무 검토가 필요한 최종 서명 동의서·개인정보 처리방침·약관 evidence는 외부 게이트다. 공개 Google OAuth 실제 /turn proof는 별도 운영 게이트.

@@ -621,7 +655,7 @@
-
현재

게이트웨이(9099 claude_cli)+API(8000, DB 미가용 시 in-memory degraded)+웹(5173)을 커맨드라인 기준 정리 후 결정론적 기동(--reload 워처 불안정 회피, uvicorn 있는 python 자동 해석). Docker가 사용 가능하면 vignette-dev-db healthcheck/pg_isready/role safety를 점검하고, compose infra\.env 필수값 누락도 경고한다. -NoGateway/-NoWeb는 해당 컴포넌트 정리도 건너뛰고, API 정리는 지정 -ApiPort만 대상으로 한다. dev-login + seed 페르소나. 로그 .devlogs/(gitignore). 검증: 3포트 health OK + 실제 AI 턴 생성·회기리뷰 동작, API-only 재기동 후 8001/8010/5173/9099/20241 보존.

비고

AI 턴은 로컬 claude CLI 사용(사용량 발생). 진입점 http://localhost:5173. dev-down.ps1은 기본적으로 DB 컨테이너를 보존하고, -Db 지정 시 함께 중지한다. 기존 vignette-dev-db는 accepting connections이나 healthcheck가 없고, vignette_app은 NOBYPASSRLS로 존재하지만 현재 API startup DDL은 owner 권한이 필요해 런타임 app-role 분리는 후속이다. 주의(무DB 한정): DB 없이 degraded면 페르소나가 source:seed_fallback·degraded:true라 프론트 isUsablePersona가 막아 "회기 시작" 버튼이 비활성 → UI 세션 시작엔 실제 Postgres(127.0.0.1:55432)가 필요.

+
현재

게이트웨이(9099 claude_cli)+API(8000, DB 미가용 시 in-memory degraded)+웹(5173)을 커맨드라인 기준 정리 후 결정론적 기동(--reload 워처 불안정 회피, uvicorn 있는 python 자동 해석). Docker가 사용 가능하면 vignette-dev-db healthcheck/pg_isready/role safety를 점검하고, compose infra\.env 필수값 누락도 경고한다. -NoGateway/-NoWeb는 해당 컴포넌트 정리도 건너뛰고, API 정리는 지정 -ApiPort만 대상으로 한다. dev-login + seed 페르소나. 로그 .devlogs/(gitignore). 검증: 3포트 health OK + 실제 AI 턴 생성·회기리뷰 동작, API-only 재기동 후 8001/8010/5173/9099/20241 보존.

비고

AI 턴은 로컬 claude CLI 사용(사용량 발생). 진입점 http://localhost:5173. dev-down.ps1은 기본적으로 DB 컨테이너를 보존하고, -Db 지정 시 함께 중지한다. 기존 vignette-dev-db는 accepting connections이나 healthcheck가 없고, vignette_app은 NOBYPASSRLS로 존재한다. 이번 패키징 smoke에서 fresh compose DB는 owner/app role 분리 상태로 API healthy까지 통과했고, 기존 dev DB의 운영 role 전환은 별도 owner migration 확인 후 진행한다. 주의(무DB 한정): DB 없이 degraded면 페르소나가 source:seed_fallback·degraded:true라 프론트 isUsablePersona가 막아 "회기 시작" 버튼이 비활성 → UI 세션 시작엔 실제 Postgres(127.0.0.1:55432)가 필요.

@@ -639,6 +673,10 @@
현재

apps/api/scripts/export-openapi.pyapps/web/scripts/generate-api-types.mjs로 FastAPI OpenAPI를 apps/web/src/lib/api.gen.ts에 생성한다. npm run check:api-types가 재생성 누락을 잡는다. MeResponse, AuthConfigResponse, ConsentResponse, PersonaSummary, PersonaReviewSummary, admin/user/engine DTO, review leaf DTO, CrisisResourceResponse, teacher safety/growth leaf DTO, LearnerSessionsResponse, SessionReviewResponse, worksheet aggregate, TeacherDashboardResponse, TeacherSessionSummary, TeacherLearnerGrowth에 이어 SessionStartResponse, SessionDetailResponse, SessionDetailTurnApiSchema<...> alias로 전환했다. 세션 응답 stage는 backend StageLabel enum으로 고정해 OpenAPI가 라포|탐색|개입|정리 union을 생성한다. generated optional/default 차이는 화면 form state, notification default helper, 배열 렌더링 fallback으로 흡수한다. .github/workflows/api-contract.yml은 PR/master push에서 같은 stale check를 실행해 OpenAPI type drift를 CI에서 막는다.

검증

npm run check:api-types, npm run typecheck, npm run build passed. python -m pytest app/test_session_turn_persistence.py -q 16 passed. 영향 범위 E2E: learner.spec.ts + session-review.spec.ts + teacher.spec.ts + layout-visual-gate.spec.ts 32 passed.

잔여

평가 조회·리뷰 도식·교수 콘솔 등 아직 자유 문자열로 남긴 stage-like 필드는 각 도메인 의미를 확인한 뒤 별도 축소한다.

+
+ +
현재

apps/web/index.html에 canonical, description, Open Graph/Twitter Card, JSON-LD(WebApplication)를 추가했고, public/robots.txt, sitemap.xml, llms.txt로 공개 사이트와 AI 검색용 설명을 정리했다. 인증 내부 경로는 robots에서 차단한다. 학습자 회기 리뷰는 종료 후 POST /sessions/{id}/share로 공개 토큰을 만들고, GET /share/session/{token}이 서버 렌더 HTML/OG/JSON-LD를 반환한다. app.session_share_link에는 토큰 해시와 sanitized preview payload만 저장하며 원문 축어록·학습자 식별자는 저장하지 않는다.

검증

pytest app/test_session_share.py app/test_session_turn_persistence.py -q 21 passed, python -m pytest app/ -q 145 passed, python -m pytest engine_gateway/ -q 9 passed, npm run check:api-types, npm run typecheck, npm run build passed.

판정

공개 제품 페이지는 색인 가능, 회기 공유 페이지는 교수자 전달용 unfurl 카드라 noindex와 API robots Disallow: /share/를 유지한다. 실제 공개 도메인 unfurl 검증은 배포 후 1회 필요하다.

+
현재

LogHook/log_hook(caller 0) 타입·파라미터·분기·export 제거 · 게이트웨이/엔진클라 tier dead-contract 제거(write-only, gateway는 model만 사용). 잔여(RMS 힌트·Live2D 고아 자산·파라미터객체 init_state/prepare_turn/upsert)는 docs/ops/refactor-rag-patches-2026-06-26.{md,json}에 스테이징(단계 적용).

참고

일괄 적용 대신 라이브 스택 보호 위해 그룹별 적용+pytest 검증.

@@ -696,7 +734,7 @@
DONE
프로세스 난립 정리

운영 확인용 프로세스와 로컬/Tailnet 검증용 dev 프로세스를 의도적으로 분리해 유지한다.

산출물

9099 engine, 8001 prod API, 8000/8010 dev API, 5173 web, cloudflared tunnel 1개

검증

Get-NetTCPConnection에서 대상 포트별 listener 확인

GATE
한신대 공문/데이터 거버넌스 게이트 정리

SSO 클레임, 추가 축어록 수급, 미성년 원본 활용동의, 개인정보 처리방침을 P1 진입 전 외부 의존성으로 명확히 둔다. 로컬 문서 골격은 준비됐지만 한신대/데이터 steward의 written evidence는 아직 필요하다.

산출물

docs/ops/hanshin-data-governance-gate.md, 공문 질의 항목, 동의 범위 체크리스트, SSO claim mapping 표

검증

문서 artifact 작성 완료; IRB 게이트가 아니라 데이터/SSO 게이트로 외부 증거 필요 상태 유지

-
팀장 판정: 로컬 회귀와 공개 prod-safe 게이트는 통과했다. Cloudflare Pages production 12da49f3이 commit 0ec266a dist로 배포됐고, 운영 반영 전 남은 핵심 증거는 공개 Google OAuth 실제 /turn smoke다. 런타임 mock/demo 자산은 운영 번들에서 제거하고 테스트 fixture만 남긴다.
+
팀장 판정: 로컬 회귀와 공개 prod-safe 게이트는 통과했다. Cloudflare Pages production 24c90784가 현재 dirty local source dist로 배포됐고, 운영 반영 전 남은 핵심 증거는 공개 Google OAuth 실제 /turn smoke다. 런타임 mock/demo 자산은 운영 번들에서 제거하고 테스트 fixture만 남긴다.

Phase 0 완료 상세 목록

이전 "완료" 카드 내용을 탭 안에서 항목별로 보존한다.

DONE
DB persona 기반 테스트

session-persistence, teacher, voice/api/personas에서 실제 DB persona를 가져온다.

산출물

seed persona 직접 결합 제거, 실제 persona catalog 기반 E2E

검증

persona 관련 E2E가 DB-backed persona로 통과

@@ -735,7 +773,7 @@

Phase 2a 권한, OAuth, 페르소나 상세

음성과 섞지 않고 3역할, 권한격리, 페르소나 확장을 독립 트랙으로 닫는다.

주차: 9-13우선순위: P1IDOR 검증 필수
CHECK
OAuth BFF / HttpOnly 세션 완성

Auth Code + PKCE/state와 opaque HttpOnly/Secure cookie 계약을 회귀화했다. OAuth state는 HMAC 서명 토큰과 HttpOnly state cookie를 함께 검증해 API 재시작 뒤 콜백이 invalid_state로 떨어지지 않으면서도 브라우저 시작 흐름에 묶인다. Google provider error는 access_denied/provider_error로 분리하고 로그인 화면에 reason code를 표시한다. 공개 Google OAuth 실제 /turn proof는 P0 blocker로 남아 있다.

산출물

app/test_auth_providers.py, cookie-bound signed PKCE state, OAuth callback cookie path, login reason UI

검증

auth URL secret 미노출, browser token 미저장, dev/prod cookie 속성 분리, state cookie 불일치 차단, 재시작 후 signed state 콜백 복구, provider error reason 분리

-
DONE
학습자/교수자/관리자 권한 격리

runtime fallback에서 타 learner session 직접 접근 403과 learner list 필터를 회귀화했고, teacher/admin durable read audit insert 경로를 추가했다. live Postgres(NOBYPASSRLS vignette_app 역할)로 RLS/audit smoke를 실행해 운영 증거를 확보했다: learner 세션/턴 격리, teacher cohort 스코프, teacher/admin read_session audit insert가 실제 정책 하에서 PASS.

산출물

app/test_rbac_idor.py, audit.audit_log read_session insert, scripts/check-postgres-rls-audit.py(live PASS)

검증

타인 session_id 직접 입력 403(unit), live RLS smoke 5 checks PASS

+
DONE
학습자/교수자/관리자 권한 격리

runtime fallback에서 타 learner session 직접 접근 403과 learner list 필터를 회귀화했고, teacher/admin durable read audit insert 경로를 추가했다. live Postgres(NOBYPASSRLS vignette_app 역할)로 RLS/audit smoke를 실행해 운영 증거를 확보했다: learner 세션/턴 격리, teacher cohort 스코프, teacher/admin read_session audit insert가 실제 정책 하에서 PASS. 이번 패스에서 역할(role)과 관리자 페이지 진입권(admin_access)을 분리했고, AUTH_SUPER_ADMIN_EMAILS 슈퍼 관리자만 관리자 권한을 부여·회수할 수 있게 했다. 슈퍼 관리자는 학습자/교수자 라우트도 통과할 수 있지만, 일반 admin_access는 관리자 콘솔 진입권으로만 쓰인다.

산출물

app_user.admin_access, require_admin_access(), /admin/users 권한 위임 UI, app/test_rbac_idor.py, audit.audit_log read_session insert, scripts/check-postgres-rls-audit.py(live PASS)

검증

타인 session_id 직접 입력 403(unit), live RLS smoke 5 checks PASS, 관리자 권한 위임 회귀 app/test_auth_providers.py app/test_admin_ops.py 32 passed

DONE
visible_to와 인간 권한 레이어 분리

learner detail/review와 client engine history에서 evaluator-only turn을 제외한다. live Postgres smoke가 evaluator-only DB 정책을 실제로 검사해 PASS: evaluator-only turn이 learner/client/counselor AI에는 숨겨지고 evaluator AI에만 보인다.

산출물

TurnRecord.visible_to, learner/client/evaluator filters, docs/ops/postgres-rls-audit-smoke.md

검증

evaluator-only turn 숨김 unit proof + live RLS smoke visible_to.evaluator_only_hidden PASS

DONE
페르소나 P2/P3 교수 검수 루프

approved catalog와 draft/review 교수 검수 큐를 분리하고, teacher/admin 승인·반려 mutation과 교수 콘솔 UI proof를 추가했다. 교수 콘솔의 404 raw 노출도 제거(404→정상 빈상태)했다.

산출물

/personas/review, POST /personas/review/{persona_id}, professor review panel

검증

app.test_persona_review 14 tests; teacher UI single-run E2E 1 passed; live RLS smoke PASS

CHECK
SAML SP 스캐폴드 선반영

Google OIDC와 별도 provider 상태를 /auth/config에 노출하고, Redirect-binding AuthnRequest와 dev-only unsigned fixture ACS를 backend proof 수준으로 구현했다. 서명 검증이 필요한 운영 SAML ACS는 아직 미구현이며 fingerprint 설정 시 거부한다.

산출물

AUTH_SAML_ENABLED, SAML_SP_ENTITY_ID, SAML_SSO_URL, RelayState store, /auth/saml/acs fixture endpoint

검증

AuthnRequest issuer/ACS/destination, RelayState 검증, opaque HttpOnly cookie, bad RelayState/signature-required reject unit test

@@ -746,7 +784,7 @@
CHECK
voice gateway WebSocket

/voice/ws backend contract를 fake WebSocket으로 회귀화했고, Session UI의 MediaRecorder 기반 브라우저 경로도 synthetic audio로 한 턴 통과시켰다. 실제 AudioWorklet 전환, public WSS, 50분 양방향 실측은 남아 있다.

산출물

app/test_voice_ws.py, apps/web/e2e/voice-success.spec.ts, audio_start/binary/audio_end/text_turn/ping contract

검증

5 backend tests OK; voice-success chromium-single-run 2 passed; auth guard, chunk concat, max audio cap, TTS chunk envelope

CHECK
Deepgram STT + EOT

provider-neutral EOT helper로 final transcript와 silence threshold readiness를 회귀화했다. 실제 Deepgram interim/final 스트림 연결은 남아 있다.

산출물

assess_end_of_turn, EndOfTurnDecision, EOT silence threshold

검증

final pending, empty transcript, silence threshold 케이스 unit test

-
CHECK
OpenAI TTS voice preset 추상화

P1/P2/P3 persona voice preset, invalid preset fallback, speed clamp, instructions payload 계약을 회귀화했다. DB voice_persona_map과 live TTS 호출은 남아 있다.

산출물

resolve_voice, build_tts_payload, provider-neutral params test

검증

P1/P2/P3 voice 분리, fallback, fake-client streaming payload OK

+
DONE
OpenAI TTS voice preset 추상화

P1/P2/P3 persona voice preset, invalid preset fallback, speed clamp, instructions payload 계약을 회귀화했고, app.persona_voice_map을 live /voice/ws TTS 선택에 연결했다. 명시 preset은 DB map보다 우선하고, OpenAI가 아닌 provider row는 기존 fallback으로 안전하게 흡수한다.

산출물

resolve_voice, resolve_voice_from_map, build_tts_payload, get_session_voice_map, seed voice map row, dev runtime schema guard

검증

pytest app/test_runtime_policy.py app/test_persona_review.py app/test_voice_service.py app/test_voice_ws.py -q 75 passed; full API 175 passed + gateway 9 passed.

POC
P1 서연 무참조 synthetic voice 샘플

Higgs v3 로컬 런타임에서 실존 reference voice 없이 synthetic seed를 먼저 만들고, 그 seed만 재참조해 우울·피로·불안·라포·회복감 5개 변주를 생성했다. 개발용 VIGNETTE_VOICE_POC_SAMPLE_TTS 플래그로 /voice/ws P1 sample TTS provider에도 연결했다.

산출물

docs/voice-art/bimec-persona-voice-poc-2026-06-27.md, docs/voice-art/p1-seoyeon-higgs-v3-20260627/manifest.json, apps/api/app/services/voice.py

검증

mp3/wav 6개(seed+5) 생성, 24kHz, 7.72~11.68초. live /voice/ws text_turn smoke: provider p1-sample-poc, binary 20 chunks/78,573 bytes. 샘플 provider는 TTS override만 담당하고 마이크/STT는 OpenAI 키가 필요하다. 프로덕션 탑재는 모델 라이선스·권리정책 결정 전 금지.

CHECK
barge-in / paralinguistic logging

음성 learner turn에 audio_ref, silence_ms, speech_rate, barge_in을 nullable 메타로 저장하고, 회기 리뷰에서 침묵·발화 속도·끼어듦·음성 입력 비언어 칩으로 표시한다. 실제 장시간 WSS/마이크와 한숨·울음 감지 증거는 남아 있다.

산출물

turns nullable 확장, voice route 메타 산출, ReviewNonverbalEvent, SessionReview 칩 UI

검증

오디오 STT→엔진→TTS 더블 테스트에서 voice turn log에 메타 저장; session review E2E 1 passed; backend voice/session 17 passed

CHECK
s2s 2차 PoC 판단

캐스케이드 1차 원칙과 s2s 유지/폐기 증거 기준을 문서화했다. 채택 여부 결정은 윤찬 DECIDE로 유지한다.

산출물

docs/decisions/voice-s2s-poc.md

검증

transcript, safety, audit, latency, failure, privacy 기준 명시

@@ -763,18 +801,20 @@
-
닫은 항목(live 증거): ① engine config 운영값(claude_cli / 9099 / gateway-default, durable, db). ② 상주 엔진풀 probe — 단일 session_id 2회 reused stream, TTFT 1667–4199ms, cost 누적 0.068→0.123. ③ Postgres RLS/audit smoke 5 checks PASS. ④ turn cost telemetry — app.turns client_ai 13행에 provider/model/cost_usd 실적재(합 $1.22). ⑤ 레이아웃 시각 게이트 7/7 + 적대적 재검수 7/7 accept.
-
환경 제약으로 이 세션에서 못 닫는 항목(정직 표기): 공개 Google OAuth 실제 /turn(owner 지시로 보류) · vnet.18ka.net/api-vnet.18ka.net 공개 런타임(DNS 미해결, Cloudflare 18ka.net zone 권한 불일치, Google OAuth redirect URI 등록 필요) · 한신대 데이터/SSO 거버넌스(owner 지시로 보류) · 음성 live STT/TTS provider·물리 마이크·공개 WSS 50분 · 저항엔진 openness 곡선 DB 실증 · claude_cli↔Messages API 폴백 동일성 · 재부팅 후 watchdog smoke · Phase 3 파일럿 게이트 · decisions 6건(owner 결정). 운영 원칙상 가짜 증거로 DONE 표기하지 않는다. 전체 추적: docs/ops/backlog-2026-06-26.md(B1 폴리시 · B2 환경제약 · B3 소유자결정 · B4 거버넌스).
+
닫은 항목(live 증거): ① engine config 운영값(claude_cli / 9099 / gateway-default, durable, db). ② 상주 엔진풀 probe — 단일 session_id 2회 reused stream, TTFT 1667–4199ms, cost 누적 0.068→0.123. ③ 상주 엔진풀 RSS — gateway session 1개에서 claude -p child RSS 363.3MB, gateway RSS 16.2MB. ④ Postgres RLS/audit smoke 5 checks PASS. ⑤ turn cost telemetry — app.turns client_ai 13행에 provider/model/cost_usd 실적재(합 $1.22). ⑥ 레이아웃 시각 게이트 7/7 + 적대적 재검수 7/7 accept.
+
환경 제약으로 이 세션에서 못 닫는 항목(정직 표기): 공개 Google OAuth 실제 /turn(실제 계정 storageState 필요) · infra/.env compose 필수값(APP_DB_PASSWORD, OAUTH_GOOGLE_CLIENT_ID, OAUTH_GOOGLE_CLIENT_SECRET) · vnet.18ka.net/api-vnet.18ka.net 공개 런타임(DNS 미해결, Cloudflare 18ka.net zone 권한 불일치, Google OAuth redirect URI 등록 필요) · 한신대 데이터/SSO 거버넌스(owner/기관 written evidence 필요) · 음성 live STT/TTS provider·물리 마이크·공개 WSS 50분 · 저항엔진 openness 곡선 DB 실증 · claude_cli↔Messages API 폴백 동일성 · 재부팅 후 watchdog smoke · Phase 3 파일럿 게이트 · decisions 6건(owner 결정). 운영 원칙상 가짜 증거로 DONE 표기하지 않는다. 전체 추적: docs/ops/backlog-2026-06-26.md(B1 폴리시 · B2 환경제약 · B3 소유자결정 · B4 거버넌스).