// Settings.jsx — Account + Settings detail pages. // Wired panes: Profile (user from /auth/me), Defaults + Appearance (PUT /settings). // Other panes remain mock demos since canon_lm_server has no backing endpoints // (billing, key catalog, sessions, 2FA, notifications, shortcuts UI). function SettingsShell({ initial = 'account-profile', onBack, user, setUser, onSignOut }) { const [sel, setSel] = React.useState(initial); const [modal, setModal] = React.useState(null); const [valEdit, setValEdit] = React.useState(null); const [settings, setSettings] = React.useState(null); const [savedAt, setSavedAt] = React.useState(null); React.useEffect(() => { window.__chOpenModal = setModal; window.__chOpenValueEdit = setValEdit; }, []); // Load /settings once React.useEffect(() => { let cancelled = false; window.API.getSettings() .then(s => { if (!cancelled) setSettings(s); }) .catch(() => {}); return () => { cancelled = true; }; }, []); const updateSettings = async (patch) => { setSettings(s => ({ ...(s || {}), ...patch })); try { const updated = await window.API.putSettings(patch); setSettings(updated); setSavedAt(Date.now()); setTimeout(() => setSavedAt(null), 1500); } catch (_) {} }; const sections = [ { group: '계정', items: [ { id: 'account-profile', label: '프로필' }, { id: 'account-plan', label: '요금제 · 결제' }, { id: 'account-keys', label: 'API 키' }, { id: 'account-sessions', label: '세션' }, { id: 'account-security', label: '보안' }, ]}, { group: '설정', items: [ { id: 'set-appearance', label: '외관' }, { id: 'set-defaults', label: '채팅 기본값' }, { id: 'set-notifications', label: '알림' }, { id: 'set-shortcuts', label: '단축키' }, { id: 'set-data', label: '데이터 · 개인정보' }, ]}, ]; return ( <>
{sel === 'account-profile' && } {sel === 'account-plan' && } {sel === 'account-keys' && } {sel === 'account-sessions' && } {sel === 'account-security' && } {sel === 'set-appearance' && } {sel === 'set-defaults' && } {sel === 'set-notifications' && } {sel === 'set-shortcuts' && } {sel === 'set-data' && }
{modal === 'new-key' && setModal(null)}/>} {modal === 'recovery' && setModal(null)}/>} {modal === 'change-pw' && setModal(null)}/>} {modal === 'totp' && setModal(null)}/>} {modal === 'upgrade' && setModal(null)}/>} {modal === 'photo' && setModal(null)}/>} {modal === 'export' && setModal(null)}/>} {modal === 'confirm-revoke-key' && setModal(null)}/>} {modal === 'confirm-signout' && setModal(null)}/>} {modal === 'confirm-delete-account' && setModal(null)}/>} {modal === 'confirm-delete-convos' && setModal(null)}/>} {valEdit && setValEdit(null)}/>} ); } /* ---- Account ---- */ function ProfilePane({ user, setUser }) { const initialName = (user && user.display_name) || ''; const email = user ? user.email : '—'; const [name, setName] = React.useState(initialName); const [busy, setBusy] = React.useState(false); const [msg, setMsg] = React.useState(null); React.useEffect(() => { setName(initialName); }, [initialName]); const dirty = name.trim() !== (initialName || '').trim(); const initial = ((name || email || '?')[0] || '?').toUpperCase(); const role = user && user.is_admin ? '관리자 · 워크스페이스 소유자' : '일반 사용자'; const joined = user && user.created_at ? new Date(user.created_at * 1000).toISOString().slice(0, 10) : '—'; const save = async () => { if (!dirty || busy) return; setBusy(true); setMsg(null); try { const updated = await window.API.updateProfile({ display_name: name.trim() || null }); if (setUser) setUser(updated); setMsg({ ok: true, text: '저장됨' }); setTimeout(() => setMsg(null), 1500); } catch (e) { setMsg({ ok: false, text: e.message || '저장 실패' }); } finally { setBusy(false); } }; return <>
계정 · 프로필

GBR 에서 보이는 신원.

/auth/me 응답 기반. 표시 이름은 즉시 저장됩니다. 이메일 변경은 별도 sprint 예정.

{initial}
{name || email}
{email}
{role}
표시 이름 이름이 비면 이메일이 표시됩니다
setName(e.target.value)} placeholder={email} />
{msg && (
{msg.text}
)}
이메일 로그인 시 사용
사용자 ID
{user ? '#' + user.id : '—'}
가입일
{joined}
관리자 권한
{user && user.is_admin ? '예' : '아니오'}

이 계정에서 로그아웃

현재 디바이스의 토큰을 무효화하고 로그인 화면으로 돌아갑니다.

{user && (user.display_name || user.email)} 에서 로그아웃다시 사용하려면 비밀번호 입력 필요
; } function PlanPane() { return <>
계정 · 요금제 · 결제

요금제 · 사용량.

현재 Lab 요금제 (예시 mockup) · 2026-12-12 갱신 예정. 우리는 byte 기반 과금을 검토 중입니다 (3 bytes ≈ 1 token, 한국어 차별 보정).

사용 바이트
1.24 MB
월 한도 50 MB
대화 수
1,284
월 한도 5,000
예상 청구
₩ 0
데모 기간 무료
요금제
Lab · ₩ 0/월 (데모)
결제 수단 준비 중 · 토스 연동 예정
미설정
청구 이메일
billing@gbr.ai
청구서 내역
; } function KeysPane() { return <>
계정 · API 키

API 키.

현재 서버에 API 키 발급/관리 기능이 없습니다. 곧 사용 가능합니다 (별 sprint 예정).

예정된 기능:

  • 키 발급 (scopes: read / write / admin)
  • 마지막 사용 시각 + IP 추적
  • 키 폐기 (revoke) — 즉시 401
  • 키당 rate limit 설정
; } function SessionsPane() { return <>
계정 · 세션

활성 세션.

현재 서버는 JWT stateless 인증만 사용해 세션 추적 기능이 없습니다. 별 sprint 예정.

예정된 기능:

  • 로그인 디바이스/위치/IP 목록
  • 개별 세션 폐기
  • 다른 모든 세션에서 로그아웃
  • 로그인 알림 (새 기기 감지)
이 디바이스에서 로그아웃 로컬 토큰 무효화
; } function SecurityPane({ onSignOut }) { return <>
계정 · 보안

보안.

비밀번호 해시는 PBKDF2-SHA256 (200k iterations) 사용 중. 2단계 인증/복구 코드/SSO 등은 별 sprint 예정.

2단계 인증 (TOTP)준비 중
비활성
복구 코드준비 중
SSO (SAML)준비 중
로그인 알림준비 중 · 새 기기 감지 시 이메일
; } function ChangePasswordCard() { const [cur, setCur] = React.useState(''); const [nw, setNw] = React.useState(''); const [nw2, setNw2] = React.useState(''); const [busy, setBusy] = React.useState(false); const [msg, setMsg] = React.useState(null); const valid = cur && nw.length >= 8 && nw === nw2; const submit = async (e) => { e.preventDefault(); if (!valid || busy) return; setBusy(true); setMsg(null); try { await window.API.changePassword(cur, nw); setCur(''); setNw(''); setNw2(''); setMsg({ ok: true, text: '비밀번호가 변경되었습니다.' }); setTimeout(() => setMsg(null), 2500); } catch (e) { setMsg({ ok: false, text: e.message || '변경 실패' }); } finally { setBusy(false); } }; return (

비밀번호 변경

최소 8자. 변경해도 현재 세션은 유지됩니다 (다른 디바이스 강제 로그아웃은 별도 sprint 예정).

현재 비밀번호
setCur(e.target.value)} autoComplete="current-password"/>
새 비밀번호8자 이상
setNw(e.target.value)} autoComplete="new-password" minLength={8}/>
새 비밀번호 확인
setNw2(e.target.value)} autoComplete="new-password" minLength={8}/>
{nw2 && nw !== nw2 && (
비밀번호가 일치하지 않습니다.
)} {msg && (
{msg.text}
)}
); } function DeleteSelfCard({ onSignOut }) { const [pw, setPw] = React.useState(''); const [phrase, setPhrase] = React.useState(''); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(null); const armed = phrase.trim() === 'DELETE' && pw.length > 0; const submit = async () => { if (!armed || busy) return; if (!window.confirm('정말로 계정을 삭제하시겠어요? 대화 기록도 함께 삭제됩니다. 되돌릴 수 없습니다.')) return; setBusy(true); setErr(null); try { await window.API.deleteSelf(pw); if (onSignOut) onSignOut(); else location.reload(); } catch (e) { setErr(e.message || '탈퇴 실패'); setBusy(false); } }; return (

위험 작업 · 회원 탈퇴

계정과 모든 대화 기록이 즉시 삭제됩니다. 되돌릴 수 없습니다. 관리자 계정인 경우 마지막 관리자가 남아 있을 때는 탈퇴할 수 없습니다.

비밀번호 확인
setPw(e.target.value)} autoComplete="current-password"/>
DELETE 입력
setPhrase(e.target.value)} placeholder="DELETE" autoCapitalize="characters"/>
{err && (
{err}
)}
); } /* ---- Settings ---- */ function SavedChip({ savedAt }) { if (!savedAt) return null; return SAVED; } function AppearancePane({ settings, updateSettings, savedAt }) { const theme = settings && settings.theme ? settings.theme : 'dark'; const pickTheme = (t) => { if (window.applyTheme) window.applyTheme(t); if (updateSettings) updateSettings({ theme: t }); }; const themes = [ { id: 'dark', label: '다크' }, { id: 'light', label: '라이트' }, { id: 'system', label: '시스템' }, ]; return <>
설정 · 외관

화면 테마.

서버에 즉시 저장되며 다음 접속 시에도 유지됩니다. '시스템'은 OS 다크모드 설정을 따라갑니다.

테마
{themes.map(o => ( ))}
밀도 준비 중
{['컴팩트','보통','넓게'].map((o,i) => )}
모션 줄이기 준비 중 · 펄스/페이드 비활성화
; } function DefaultsPane({ settings, updateSettings, savedAt }) { const temp = settings ? settings.default_temp : 0.2; const topP = settings ? settings.default_top_p : 0.95; const maxTok = settings ? settings.default_max_tokens : 512; const sysPrompt = settings ? (settings.system_prompt || '') : ''; const [localSys, setLocalSys] = React.useState(sysPrompt); React.useEffect(() => { setLocalSys(sysPrompt); }, [sysPrompt]); const set = (k, v) => updateSettings && updateSettings({ [k]: v }); // Live model identity from the engine (/v1/models). The Rust proxy ignores // any client-supplied `model` and uses whatever the M3 sidecar reports, so // a static dropdown would be misleading — we surface the auto-detected name // read-only and let the LLM team swap it via CPN_VERSION on the M3 box. const [liveModel, setLiveModel] = React.useState(null); React.useEffect(() => { let cancelled = false; window.API.liveModel().then(id => { if (!cancelled) setLiveModel(id); }); return () => { cancelled = true; }; }, []); // Token guidance: 한국어는 byte-level (UTF-8 3 bytes/char) 이라 영문 대비 토큰 효율 다름. // 우리 byte-level 엔진은 1 byte = 1 token. 외부 LLM은 1 word ≈ 1.3 tokens 정도. return <>
설정 · 채팅 기본값

채팅 기본값.

새 대화에 자동 적용됩니다. 슬라이더 값은 PUT /settings 로 즉시 저장됩니다.

현재 모델M3 엔진의 /v1/models 에서 자동 감지. 변경은 M3 의 CPN_VERSION 환경변수로.
{liveModel || '— 엔진 응답 대기 중 —'}
Temperature0 = 항상 같은 단정한 답 / 2 = 매번 다른 창의적 답. 0.2가 안정·일관, 0.8이 사람 대화 톤.
set('default_temp', +e.target.value)} style={{flex:1, marginRight:12}}/> {temp.toFixed(2)}
Top-p다음 단어 후보 누적 확률 cutoff. 0.95 = 상위 95% 확률 안에서만 선택 → tail 잘라 안전. 1.0이면 모든 후보.
set('default_top_p', +e.target.value)} style={{flex:1, marginRight:12}}/> {topP.toFixed(2)}
Max tokens응답 최대 길이. 우리 V16 엔진은 byte-level (1 byte ≈ 1 token, 한글 1자 = 3 bytes). 512면 한글 약 170자.
set('default_max_tokens', +e.target.value)} style={{flex:1, marginRight:12}}/> {maxTok}
스트리밍 응답토큰 생성 즉시 한 글자씩 표시 (stream:true)

시스템 프롬프트

모든 새 대화 시작 시 모델에 먼저 들어가는 지시문. 비워두면 적용 안 됨.