d16456cb92
# 사용자별 격리 - JWT 토큰에 uid 추가 (auth.get_uid 헬퍼) - PostgreSQL — exchange_credentials/automation_config/trades/signal_events 에 user_id BIGINT - SQLite user_settings 테이블 신설 (글로벌 settings 는 옛 호환) - 모든 DB 함수 시그니처에 user_id 인자 추가 — 다른 사용자 데이터 절대 접근 불가 - alert_state — 모든 dict key 가 (user_id, ...) tuple 로 계층화 - core_logic alert_loop — 활성 사용자 순회 + 각자 settings/symbol/텔레그램 적용 - ensure_user_defaults() / ensure_user_automation() — 첫 사용 시 자동 시드 # 사용자 관리 (admin only) - users_db: delete_user / admin_reset_password / set_role - /api/users POST DELETE PUT password PUT role (본인 강등 / 마지막 admin 보호) - /admin/users 페이지 — 등록/삭제/role 토글/비번 reset 모달 - 사이드바 adminOnly 필터 — admin role 만 메뉴 노출 # 대시보드 개선 - 모바일 / 범례 토글 (모바일 60 캔들, 데스크톱 200) - 트레이드 이력: open 트레이드 실시간 PnL% (Binance ticker 호출 + 방향별 계산) - 메트릭 카드 분리 (실거래 vs 실시간 open) # 안정성 - api.ts: error.detail array/object 안전 처리 ([object Object] 방지) - Chart.tsx: Plotly yaxis title 객체 형태 + 모바일 height 동적 조정 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
212 lines
8.7 KiB
TypeScript
212 lines
8.7 KiB
TypeScript
'use client';
|
|
import { useEffect, useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { api } from '@/lib/api';
|
|
import { useAuth } from '@/lib/auth';
|
|
import { Card, PageHeader, Input, Select, Button, Banner } from '@/components/ui';
|
|
import { UserPlus, KeyRound, Trash2, Shield, ShieldCheck } from 'lucide-react';
|
|
|
|
interface User {
|
|
id: number;
|
|
username: string;
|
|
role: string;
|
|
created_at: string;
|
|
last_login_at: string | null;
|
|
}
|
|
|
|
export default function AdminUsersPage() {
|
|
const router = useRouter();
|
|
const { user } = useAuth();
|
|
const [users, setUsers] = useState<User[]>([]);
|
|
const [form, setForm] = useState({ username: '', password: '', role: 'user' });
|
|
const [msg, setMsg] = useState<{ level: any; text: string } | null>(null);
|
|
const [resetTarget, setResetTarget] = useState<number | null>(null);
|
|
const [resetPw, setResetPw] = useState('');
|
|
|
|
useEffect(() => {
|
|
if (user && user.role !== 'admin') {
|
|
router.replace('/');
|
|
}
|
|
}, [user]);
|
|
|
|
async function load() {
|
|
try {
|
|
const data = await api.get<User[]>('/api/users');
|
|
setUsers(data);
|
|
} catch (e: any) {
|
|
setMsg({ level: 'danger', text: e.message });
|
|
}
|
|
}
|
|
useEffect(() => { load(); }, []);
|
|
|
|
async function add(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setMsg(null);
|
|
try {
|
|
await api.post('/api/users', form);
|
|
setMsg({ level: 'success', text: `✅ ${form.username} 생성 완료` });
|
|
setForm({ username: '', password: '', role: 'user' });
|
|
load();
|
|
} catch (e: any) {
|
|
setMsg({ level: 'danger', text: e.message });
|
|
}
|
|
}
|
|
|
|
async function del(u: User) {
|
|
if (!confirm(`'${u.username}' 계정을 삭제하시겠습니까?`)) return;
|
|
try {
|
|
await api.delete(`/api/users/${u.id}`);
|
|
setMsg({ level: 'success', text: `🗑️ ${u.username} 삭제됨` });
|
|
load();
|
|
} catch (e: any) {
|
|
setMsg({ level: 'danger', text: e.message });
|
|
}
|
|
}
|
|
|
|
async function changeRole(u: User, newRole: string) {
|
|
if (!confirm(`'${u.username}' role 을 ${newRole} 로 변경하시겠습니까?`)) return;
|
|
try {
|
|
await api.put(`/api/users/${u.id}/role`, { role: newRole });
|
|
setMsg({ level: 'success', text: `🔄 ${u.username} → ${newRole}` });
|
|
load();
|
|
} catch (e: any) {
|
|
setMsg({ level: 'danger', text: e.message });
|
|
}
|
|
}
|
|
|
|
async function resetPassword(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (resetTarget == null) return;
|
|
if (resetPw.length < 6) {
|
|
setMsg({ level: 'danger', text: '비밀번호 6자 이상' });
|
|
return;
|
|
}
|
|
try {
|
|
await api.put(`/api/users/${resetTarget}/password`, { new_password: resetPw });
|
|
setMsg({ level: 'success', text: '🔑 비밀번호 변경 완료' });
|
|
setResetTarget(null);
|
|
setResetPw('');
|
|
} catch (e: any) {
|
|
setMsg({ level: 'danger', text: e.message });
|
|
}
|
|
}
|
|
|
|
if (user && user.role !== 'admin') {
|
|
return (
|
|
<div>
|
|
<Banner level="danger">관리자 전용 페이지입니다.</Banner>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader title="🛡️ 사용자 관리" subtitle={`등록된 사용자 ${users.length}명 · 관리자 전용`} />
|
|
|
|
{msg && <div className="mb-4"><Banner level={msg.level}>{msg.text}</Banner></div>}
|
|
|
|
<div className="grid lg:grid-cols-3 gap-5 mb-5">
|
|
{/* 새 사용자 추가 */}
|
|
<Card>
|
|
<div className="flex items-center gap-2 mb-3 text-blue-600">
|
|
<UserPlus size={16} /> <span className="font-bold text-slate-800 text-sm">새 사용자 추가</span>
|
|
</div>
|
|
<form onSubmit={add} className="space-y-3">
|
|
<Input label="아이디" value={form.username}
|
|
onChange={(e: any) => setForm({ ...form, username: e.target.value })}
|
|
placeholder="username" required autoComplete="off" />
|
|
<Input label="비밀번호 (6자 이상)" type="password" value={form.password}
|
|
onChange={(e: any) => setForm({ ...form, password: e.target.value })}
|
|
required autoComplete="new-password" />
|
|
<Select label="권한" value={form.role}
|
|
onChange={(e: any) => setForm({ ...form, role: e.target.value })}>
|
|
<option value="user">user</option>
|
|
<option value="admin">admin</option>
|
|
</Select>
|
|
<Button type="submit" className="w-full">생성</Button>
|
|
</form>
|
|
</Card>
|
|
|
|
{/* 사용자 목록 */}
|
|
<Card className="lg:col-span-2">
|
|
<div className="flex items-center gap-2 mb-3 text-blue-600">
|
|
<ShieldCheck size={16} /> <span className="font-bold text-slate-800 text-sm">사용자 목록</span>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full text-xs">
|
|
<thead className="bg-slate-50 text-slate-600">
|
|
<tr>
|
|
{['ID', '아이디', '권한', '가입', '마지막 로그인', '작업'].map(h => (
|
|
<th key={h} className="px-3 py-2 text-left font-semibold border-b border-slate-200">{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.length === 0 && <tr><td colSpan={6} className="p-4 text-center text-slate-400">로딩 중...</td></tr>}
|
|
{users.map(u => {
|
|
const isMe = u.username === user?.username;
|
|
return (
|
|
<tr key={u.id} className="border-b border-slate-100 hover:bg-slate-50">
|
|
<td className="px-3 py-2 font-mono text-slate-500">#{u.id}</td>
|
|
<td className="px-3 py-2 font-semibold">
|
|
{u.username}
|
|
{isMe && <span className="ml-1 text-[10px] text-blue-600">(나)</span>}
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-[10px] rounded font-semibold ${u.role === 'admin' ? 'bg-amber-100 text-amber-800' : 'bg-slate-100 text-slate-700'}`}>
|
|
{u.role === 'admin' && <Shield size={10} />}
|
|
{u.role}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2 font-mono text-slate-500">{(u.created_at || '').slice(0, 16).replace('T', ' ')}</td>
|
|
<td className="px-3 py-2 font-mono text-slate-500">{(u.last_login_at || '-').slice(0, 16).replace('T', ' ')}</td>
|
|
<td className="px-3 py-2">
|
|
<div className="flex gap-1 flex-wrap">
|
|
<Button size="sm" variant="secondary" onClick={() => { setResetTarget(u.id); setResetPw(''); }}>
|
|
<KeyRound size={11} /> 비번
|
|
</Button>
|
|
{!isMe && (
|
|
<Button size="sm" variant="secondary"
|
|
onClick={() => changeRole(u, u.role === 'admin' ? 'user' : 'admin')}>
|
|
{u.role === 'admin' ? '↓ user' : '↑ admin'}
|
|
</Button>
|
|
)}
|
|
{!isMe && (
|
|
<Button size="sm" variant="danger" onClick={() => del(u)}>
|
|
<Trash2 size={11} />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 비번 리셋 모달 */}
|
|
{resetTarget != null && (
|
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setResetTarget(null)}>
|
|
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-sm mx-4" onClick={e => e.stopPropagation()}>
|
|
<h3 className="text-base font-bold mb-3">비밀번호 초기화</h3>
|
|
<p className="text-xs text-slate-500 mb-3">
|
|
<code>{users.find(u => u.id === resetTarget)?.username}</code> 의 새 비밀번호를 설정합니다.
|
|
</p>
|
|
<form onSubmit={resetPassword} className="space-y-3">
|
|
<Input label="새 비밀번호 (6자 이상)" type="password" value={resetPw}
|
|
onChange={(e: any) => setResetPw(e.target.value)} autoFocus required />
|
|
<div className="flex gap-2 pt-2">
|
|
<Button type="submit" className="flex-1">변경</Button>
|
|
<Button type="button" variant="secondary" className="flex-1" onClick={() => setResetTarget(null)}>취소</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|