Files
joke-app/apps/web/src/pages/MePage.tsx
EJClaw 242ab3ad71 M1.5 관리자 사이트: 슈퍼어드민/어드민 권한, 게임·사이트 설정, 방·사용자 관리, 기록
- 권한: 디스코드 ID로 요청마다 계산. 슈퍼어드민 293719842274541579(SUPERADMIN_DISCORD_IDS),
  어드민은 슈퍼어드민이 디스코드 ID로 추가(로그인 전 미리 등록 가능), 고정 슈퍼어드민은 삭제 불가
- 게임 설정: 사용 여부, 표시 이름·설명·안내문, 원작 범위 안 인원, 모든 규칙 옵션 기본값,
  옵션별 방장 변경 금지(잠금), 초기화. 입력 화면은 옵션 스키마(zod→JSON Schema)로 자동 생성
- 오목 옵션 전체에 한국어 제목·선택지 메타, 엔진 고정값(자동 착수 허용 횟수, 무승부 재제안 간격,
  한 수 제한 초)을 옵션으로 꺼냄
- 사이트 설정: 이름, 공지, 점검 모드, 금지어, 방 수 제한, 연결 유예, 채팅 기본값
- 방 관리(목록·닫기), 사용자 관리(검색·닉네임 변경·이용 제한·강제 로그아웃·삭제, 슈퍼어드민 전용),
  관리자 작업 기록(전/후 값)
- 이용 제한: 세션 삭제 + 접속 종료 + 디스코드 재로그인 거부
- 테스트: 관리자 API 7개(권한·설정 반영·잠금·점검·금지어·방 닫기·이용 제한·기록), 오목 옵션 2개,
  e2e/admin.e2e.ts(슈퍼어드민 로그인→규칙 변경·잠금→어드민 추가→게스트 새 방 반영)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:52:38 +09:00

213 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 계정 페이지 (docs/05 §7). */
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { catalogById, nicknameError, normalizeNickname } from '@bg/shared/lite';
import { Avatar, ConfirmSheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm';
import { api, discordLoginUrl } from '../net/api';
import { useSession } from '../store/session';
import { toast } from '../store/toast';
function Seg<T extends string>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{label}</strong>
<div className="segmented" role="group" aria-label={label}>
{options.map(([k, l]) => (
<button key={k} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
</div>
);
}
export default function MePage() {
const nav = useNavigate();
const { me, settings, discordEnabled, useAvatar, hasDiscordAvatar, updateSettings, role } = useSession();
const [editing, setEditing] = useState(false);
const [nick, setNick] = useState(me?.nickname ?? '');
const [error, setError] = useState<string | null>(null);
const [stats, setStats] = useState<{ gameId: string; played: number; wins: number; draws: number }[]>([]);
const [confirm, setConfirm] = useState<null | 'logout' | 'logoutAll' | 'delete'>(null);
useEffect(() => {
if (me) api.stats().then((r) => setStats(r.stats)).catch(() => {});
}, [me]);
const saveNick = async () => {
const n = normalizeNickname(nick);
const e = nicknameError(n);
if (e) return setError(e);
try {
const r = await api.updateMe({ nickname: n });
useSession.getState().setMe(r.me);
setEditing(false);
toast('닉네임을 바꿨어요.');
} catch (ex) {
setError((ex as Error).message);
}
};
const display = (
<section className="card stack">
<h2>화면 설정</h2>
<Seg label="글자 크기" value={settings.fontScale} options={[['normal', '보통'], ['large', '크게'], ['xlarge', '아주 크게']]} onChange={(fontScale) => updateSettings({ fontScale })} />
<Seg label="화면 색" value={settings.theme} options={[['auto', '자동'], ['light', '밝게'], ['dark', '어둡게']]} onChange={(theme) => updateSettings({ theme })} />
<Seg label="소리" value={settings.sound ? 'on' : 'off'} options={[['on', '켜기'], ['off', '끄기']]} onChange={(v) => updateSettings({ sound: v === 'on' })} />
<Seg label="진동" value={settings.vibrate ? 'on' : 'off'} options={[['on', '켜기'], ['off', '끄기']]} onChange={(v) => updateSettings({ vibrate: v === 'on' })} />
<Seg
label="돌·카드 놓기"
value={settings.tapConfirm ? 'on' : 'off'}
options={[['on', '두 번 눌러 확정(실수 방지)'], ['off', '한 번에 놓기']]}
onChange={(v) => updateSettings({ tapConfirm: v === 'on' })}
/>
</section>
);
if (!me) {
return (
<>
<TopBar title="내 정보" onBack={() => nav('/')} />
<main className="page">
<section className="card stack">
<h2>시작하기</h2>
<NicknameForm next="/me" />
</section>
{display}
</main>
</>
);
}
return (
<>
<TopBar title="내 정보" onBack={() => nav('/')} />
<main className="page">
<section className="card stack" style={{ alignItems: 'center', textAlign: 'center' }}>
<Avatar user={me} size={88} />
{editing ? (
<div className="stack" style={{ width: '100%' }}>
<input className="input" value={nick} onChange={(e) => (setNick(e.target.value), setError(null))} maxLength={20} autoFocus aria-label="새 닉네임" />
{error && <p className="field-error">{error}</p>}
<div className="row">
<button className="btn btn-primary" style={{ flex: 1 }} onClick={saveNick}>
저장
</button>
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={() => (setEditing(false), setNick(me.nickname), setError(null))}>
취소
</button>
</div>
<p className="muted small">게임 중에 바꾸면 그 게임이 끝난 뒤에 보여요.</p>
</div>
) : (
<>
<h1>{me.nickname}</h1>
<button className="btn btn-secondary" onClick={() => (setNick(me.nickname), setEditing(true))}>
✏️ 닉네임 바꾸기
</button>
</>
)}
<p className="muted">{me.kind === 'member' ? '디스코드로 로그인함' : '게스트(이 기기에만 저장돼요)'}</p>
{me.kind === 'guest' && discordEnabled && (
<a className="btn btn-soft btn-block" href={discordLoginUrl('/me')}>
디스코드 연결하기 (기록을 안전하게 보관)
</a>
)}
{me.kind === 'member' && hasDiscordAvatar && (
<button
className="btn btn-ghost"
onClick={async () => {
await api.updateMe({ useAvatar: !useAvatar });
await useSession.getState().refresh();
}}
>
{useAvatar ? '디스코드 프로필 사진 숨기기' : '디스코드 프로필 사진 쓰기'}
</button>
)}
</section>
{role !== 'user' && (
<button className="btn btn-soft btn-block" onClick={() => nav('/admin')}>
🛠 관리자 페이지 ({role === 'superadmin' ? '슈퍼어드민' : '어드민'})
</button>
)}
{display}
<section className="card stack">
<h2>내 기록</h2>
{stats.length === 0 ? (
<p className="muted">아직 끝까지 한 게임이 없어요.</p>
) : (
stats.map((s) => (
<div key={s.gameId} className="row" style={{ justifyContent: 'space-between' }}>
<strong>{catalogById(s.gameId)?.nameKo ?? s.gameId}</strong>
<span>
{s.played}판 {s.wins}승{s.draws ? ` ${s.draws}무` : ''}
</span>
</div>
))
)}
</section>
<section className="stack">
<button className="btn btn-secondary btn-block" onClick={() => setConfirm('logout')}>
로그아웃
</button>
{me.kind === 'member' && (
<button className="btn btn-ghost btn-block" onClick={() => setConfirm('logoutAll')}>
모든 기기에서 로그아웃
</button>
)}
<button className="btn btn-ghost btn-block small muted" onClick={() => setConfirm('delete')}>
계정 삭제
</button>
</section>
{confirm === 'logout' && (
<ConfirmSheet
title="로그아웃할까요?"
body={me.kind === 'guest' ? '게스트는 로그아웃하면 이 이름과 기록으로 다시 들어올 수 없어요.' : undefined}
confirm="로그아웃"
danger={me.kind === 'guest'}
onConfirm={async () => {
await api.logout();
await useSession.getState().refresh();
nav('/');
}}
onClose={() => setConfirm(null)}
/>
)}
{confirm === 'logoutAll' && (
<ConfirmSheet
title="모든 기기에서 로그아웃할까요?"
confirm="모두 로그아웃"
onConfirm={async () => {
await api.logoutAll();
await useSession.getState().refresh();
nav('/');
}}
onClose={() => setConfirm(null)}
/>
)}
{confirm === 'delete' && (
<ConfirmSheet
title="계정을 삭제할까요?"
body="닉네임과 연결 정보가 지워지고 되돌릴 수 없어요."
confirm="삭제하기"
danger
onConfirm={async () => {
await api.deleteMe();
await useSession.getState().refresh();
nav('/');
}}
onClose={() => setConfirm(null)}
/>
)}
</main>
</>
);
}