// 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}
{msg && (
)}
사용자 ID
{user ? '#' + user.id : '—'}
관리자 권한
{user && user.is_admin ? '예' : '아니오'}
이 계정에서 로그아웃
현재 디바이스의 토큰을 무효화하고 로그인 화면으로 돌아갑니다.
{user && (user.display_name || user.email)} 에서 로그아웃다시 사용하려면 비밀번호 입력 필요
>;
}
function PlanPane() {
return <>
계정 · 요금제 · 결제
요금제 · 사용량.
현재 Lab 요금제 (예시 mockup) · 2026-12-12 갱신 예정. 우리는 byte 기반 과금을 검토 중입니다 (3 bytes ≈ 1 token, 한국어 차별 보정).
요금제
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 예정.
로그인 알림준비 중 · 새 기기 감지 시 이메일
>;
}
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 (
);
}
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 (
위험 작업 · 회원 탈퇴
계정과 모든 대화 기록이 즉시 삭제됩니다. 되돌릴 수 없습니다. 관리자 계정인 경우
마지막 관리자가 남아 있을 때는 탈퇴할 수 없습니다.
{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 || '— 엔진 응답 대기 중 —'}
스트리밍 응답토큰 생성 즉시 한 글자씩 표시 (stream:true)
시스템 프롬프트
모든 새 대화 시작 시 모델에 먼저 들어가는 지시문. 비워두면 적용 안 됨.
>;
}
function NotificationsPane() {
return <>
설정 · 알림
알림.
현재 서버에 알림 발송 기능이 없습니다. 별 sprint 예정 (이메일/웹훅/인앱).
주간 요약매주 월요일 09:00 (Asia/Seoul)
>;
}
function ShortcutsPane() {
const M = window.MOD_KEY || 'Ctrl';
const S = window.SHIFT_KEY || 'Shift';
const ENT = window.ENTER_KEY || 'Enter';
const list = [
{ l: '새 대화', k: [M, 'N'] },
{ l: '스레드 검색', k: [M, 'K'] },
{ l: '메시지 전송', k: [ENT] },
{ l: '여러 줄 입력', k: [S, ENT] },
{ l: '응답 재생성', k: [M, S, 'R'] },
{ l: '설정 열기', k: [M, ','] },
{ l: '모델 전환', k: [M, S, 'M'] },
{ l: '플레이그라운드 토글', k: [M, '.'] },
];
return <>
설정 · 단축키
키보드 단축키.
키보드 우선 워크플로우용. 현재 OS({window.IS_MAC ? 'macOS' : 'Windows/Linux'}) 기준으로 표시됩니다.
{list.map((row, i) =>
{row.l}
{row.k.map((kk, j) => {kk})}
)}
>;
}
function DataPane() {
return <>
설정 · 데이터 · 개인정보
데이터 · 개인정보.
현재 대화는 서버 sqlite 에 저장됩니다. 서버 비용 부담 줄이기 위해 곧 로컬 저장 전환을 검토 중입니다 (IndexedDB / file-system).
저장 위치현재 서버 sqlite · 향후 로컬 전환 검토
서버
모든 데이터 내보내기JSON 다운로드
모든 대화 삭제
>;
}
window.SettingsShell = SettingsShell;