feat: add ESP32 DIY platform Phase 1 (marketplace scaffold)
- Docker Compose with Postgres, Redis, MinIO, backend, frontend (port 3200/3201) - Prisma schema: User, Project, ProjectFile, Product, Order, FlashToken, Review, AuditLog - Backend: JWT auth, project CRUD + file upload (MinIO + sharp WebP), admin approval flow - Frontend: React + Vite SPA with auth, project/shop browse, seller dashboard, admin panel - Admin: pending approval queue, user management, audit log viewer, stats dashboard - Audit logging middleware for legal compliance - Admin init script: createAdmin.js - Full design document in PLATFORM_DESIGN.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
57
platform/frontend/src/pages/Admin/Index.jsx
Normal file
57
platform/frontend/src/pages/Admin/Index.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import api from '../../api/client';
|
||||
|
||||
export default function AdminIndex() {
|
||||
const [stats, setStats] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/admin/stats').then(r => setStats(r.data)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const cards = stats ? [
|
||||
{ label: '총 사용자', value: stats.users.toLocaleString(), icon: '👤' },
|
||||
{ label: '전체 프로젝트', value: stats.projects.total.toLocaleString(), icon: '📁' },
|
||||
{ label: '승인 대기', value: stats.projects.pending.toLocaleString(), icon: '⏳', warn: stats.projects.pending > 0 },
|
||||
{ label: '총 매출', value: `₩${(stats.revenue.total || 0).toLocaleString()}`, icon: '💰' },
|
||||
{ label: '총 주문', value: stats.revenue.orders.toLocaleString(), icon: '🛒' },
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<div className="container page">
|
||||
<h2 style={{ marginBottom: 24 }}>관리자 대시보드</h2>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
{stats && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 16, marginBottom: 32 }}>
|
||||
{cards.map(c => (
|
||||
<div key={c.label} className="card" style={{ borderColor: c.warn ? 'var(--warn)' : undefined }}>
|
||||
<div style={{ fontSize: 28, marginBottom: 8 }}>{c.icon}</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: c.warn ? 'var(--warn)' : 'var(--text)' }}>{c.value}</div>
|
||||
<div className="text-muted" style={{ fontSize: 13 }}>{c.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 빠른 메뉴 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
|
||||
{[
|
||||
{ to: '/admin/projects', icon: '📋', title: '프로젝트 승인', desc: '검토 대기 중인 프로젝트를 승인/반려' },
|
||||
{ to: '/admin/users', icon: '👥', title: '사용자 관리', desc: '사용자 목록, 역할 변경, 계정 비활성화' },
|
||||
{ to: '/admin/logs', icon: '📜', title: '감사 로그', desc: '모든 주요 행동 기록 조회' },
|
||||
].map(m => (
|
||||
<Link key={m.to} to={m.to} style={{ textDecoration: 'none' }}>
|
||||
<div className="card" style={{ cursor: 'pointer', transition: 'border-color .2s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--accent)'}
|
||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}>
|
||||
<div style={{ fontSize: 32, marginBottom: 8 }}>{m.icon}</div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{m.title}</div>
|
||||
<div className="text-muted" style={{ fontSize: 13 }}>{m.desc}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
platform/frontend/src/pages/Admin/Logs.jsx
Normal file
95
platform/frontend/src/pages/Admin/Logs.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../api/client';
|
||||
|
||||
const ACTION_COLORS = {
|
||||
REGISTER: 'var(--success)', LOGIN: 'var(--success)', LOGIN_FAIL: 'var(--danger)',
|
||||
LOGOUT: 'var(--text2)', PROJECT_CREATE: 'var(--accent2)', PROJECT_SUBMIT: 'var(--warn)',
|
||||
PROJECT_DELETE: 'var(--danger)', ADMIN_APPROVE: 'var(--success)', ADMIN_REJECT: 'var(--danger)',
|
||||
ADMIN_USER_DEACTIVATE: 'var(--danger)', ORDER_CREATE: 'var(--accent2)', PAYMENT_CONFIRM: 'var(--success)',
|
||||
};
|
||||
|
||||
export default function AdminLogs() {
|
||||
const [data, setData] = useState({ logs: [], total: 0, pages: 1 });
|
||||
const [page, setPage] = useState(1);
|
||||
const [filter, setFilter] = useState({ action: '', userId: '' });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page, limit: 50 });
|
||||
if (filter.action) params.set('action', filter.action);
|
||||
if (filter.userId) params.set('userId', filter.userId);
|
||||
api.get(`/admin/logs?${params}`)
|
||||
.then(r => setData(r.data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, [page]);
|
||||
|
||||
return (
|
||||
<div className="container page">
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||
<h2>감사 로그</h2>
|
||||
<span className="text-muted" style={{ fontSize: 13 }}>총 {data.total.toLocaleString()}건</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
||||
<input placeholder="Action 필터 (예: LOGIN)" value={filter.action}
|
||||
onChange={e => setFilter(f => ({ ...f, action: e.target.value }))} style={{ maxWidth: 200 }} />
|
||||
<input placeholder="User ID" value={filter.userId}
|
||||
onChange={e => setFilter(f => ({ ...f, userId: e.target.value }))} style={{ maxWidth: 200 }} />
|
||||
<button className="btn btn-outline" onClick={() => { setPage(1); load(); }}>검색</button>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="spinner" /> : (
|
||||
<>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>시간</th><th>Action</th><th>사용자</th><th>대상</th><th>IP</th><th>상태</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.logs.map(log => (
|
||||
<tr key={log.id}>
|
||||
<td style={{ fontSize: 12, color: 'var(--text2)', whiteSpace: 'nowrap' }}>
|
||||
{new Date(log.createdAt).toLocaleString('ko-KR')}
|
||||
</td>
|
||||
<td>
|
||||
<code style={{ fontSize: 12, color: ACTION_COLORS[log.action] || 'var(--text)', background: 'var(--bg3)', padding: '2px 6px', borderRadius: 4 }}>
|
||||
{log.action}
|
||||
</code>
|
||||
</td>
|
||||
<td style={{ fontSize: 12 }}>
|
||||
{log.user ? `${log.user.nickname} (${log.user.email})` : '-'}
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text2)' }}>
|
||||
{log.targetType ? `${log.targetType} ${log.targetId?.slice(0, 8)}...` : '-'}
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text2)' }}>{log.ipAddress}</td>
|
||||
<td>
|
||||
{log.responseStatus && (
|
||||
<span style={{ fontSize: 12, color: log.responseStatus < 400 ? 'var(--success)' : 'var(--danger)' }}>
|
||||
{log.responseStatus}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{data.pages > 1 && (
|
||||
<div className="pagination">
|
||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>이전</button>
|
||||
{Array.from({ length: Math.min(data.pages, 10) }, (_, i) => (
|
||||
<button key={i + 1} className={page === i + 1 ? 'active' : ''} onClick={() => setPage(i + 1)}>{i + 1}</button>
|
||||
))}
|
||||
<button disabled={page >= data.pages} onClick={() => setPage(p => p + 1)}>다음</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
platform/frontend/src/pages/Admin/Projects.jsx
Normal file
132
platform/frontend/src/pages/Admin/Projects.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../api/client';
|
||||
|
||||
export default function AdminProjects() {
|
||||
const [pending, setPending] = useState([]);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [approveForm, setApproveForm] = useState({ price: '', commissionRate: '0.1' });
|
||||
const [rejectNote, setRejectNote] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/admin/projects/pending')
|
||||
.then(r => setPending(r.data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function handleApprove() {
|
||||
if (!approveForm.price) { setMsg('판매가를 입력해주세요'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post(`/admin/projects/${selected.id}/approve`, {
|
||||
price: parseInt(approveForm.price),
|
||||
commissionRate: parseFloat(approveForm.commissionRate),
|
||||
});
|
||||
setPending(p => p.filter(x => x.id !== selected.id));
|
||||
setSelected(null);
|
||||
setMsg('승인되었습니다');
|
||||
} catch (err) {
|
||||
setMsg(err.response?.data?.error || '오류 발생');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
if (!rejectNote.trim()) { setMsg('반려 사유를 입력해주세요'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post(`/admin/projects/${selected.id}/reject`, { adminNote: rejectNote });
|
||||
setPending(p => p.filter(x => x.id !== selected.id));
|
||||
setSelected(null);
|
||||
setRejectNote('');
|
||||
setMsg('반려 처리되었습니다');
|
||||
} catch (err) {
|
||||
setMsg(err.response?.data?.error || '오류 발생');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="spinner" />;
|
||||
|
||||
return (
|
||||
<div className="container page">
|
||||
<h2 style={{ marginBottom: 8 }}>프로젝트 승인 관리</h2>
|
||||
<p className="text-muted" style={{ marginBottom: 24 }}>검토 대기 중인 프로젝트: {pending.length}건</p>
|
||||
|
||||
{msg && <div className="alert alert-info" onClick={() => setMsg('')}>{msg}</div>}
|
||||
|
||||
{pending.length === 0 ? (
|
||||
<div className="card text-center" style={{ padding: 48 }}>
|
||||
<p className="text-muted">검토 대기 중인 프로젝트가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: selected ? '1fr 1fr' : '1fr', gap: 20 }}>
|
||||
{/* 목록 */}
|
||||
<div>
|
||||
{pending.map(p => (
|
||||
<div key={p.id} className="card" style={{ marginBottom: 12, cursor: 'pointer', borderColor: selected?.id === p.id ? 'var(--accent)' : 'var(--border)' }}
|
||||
onClick={() => { setSelected(p); setApproveForm({ price: '', commissionRate: '0.1' }); setRejectNote(''); }}>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
{p.files[0] && (
|
||||
<img src={p.files[0].thumbnailUrl || p.files[0].url} alt=""
|
||||
style={{ width: 80, height: 60, objectFit: 'cover', borderRadius: 4, flexShrink: 0 }} />
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{p.title}</div>
|
||||
<div className="text-muted" style={{ fontSize: 12 }}>
|
||||
by {p.user.nickname} · 파일 {p._count.files}개 ·{' '}
|
||||
{new Date(p.updatedAt).toLocaleDateString('ko-KR')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 상세 / 승인 패널 */}
|
||||
{selected && (
|
||||
<div className="card" style={{ height: 'fit-content', position: 'sticky', top: 80 }}>
|
||||
<h3 style={{ marginBottom: 16 }}>{selected.title}</h3>
|
||||
<p className="text-muted" style={{ fontSize: 13, marginBottom: 16, maxHeight: 120, overflow: 'auto' }}>
|
||||
{selected.description}
|
||||
</p>
|
||||
|
||||
<div className="divider" />
|
||||
<h4 style={{ marginBottom: 12 }}>승인</h4>
|
||||
<div className="form-group">
|
||||
<label>판매가 (원)</label>
|
||||
<input type="number" min="100" placeholder="예: 9900"
|
||||
value={approveForm.price} onChange={e => setApproveForm(f => ({ ...f, price: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>수수료율 (0.0 ~ 1.0)</label>
|
||||
<input type="number" min="0" max="1" step="0.01"
|
||||
value={approveForm.commissionRate}
|
||||
onChange={e => setApproveForm(f => ({ ...f, commissionRate: e.target.value }))} />
|
||||
</div>
|
||||
<button className="btn btn-success" onClick={handleApprove} disabled={saving} style={{ width: '100%', marginBottom: 12, justifyContent: 'center' }}>
|
||||
{saving ? '처리 중...' : '✓ 승인하기'}
|
||||
</button>
|
||||
|
||||
<div className="divider" />
|
||||
<h4 style={{ marginBottom: 12 }}>반려</h4>
|
||||
<div className="form-group">
|
||||
<label>반려 사유</label>
|
||||
<textarea value={rejectNote} onChange={e => setRejectNote(e.target.value)} rows={3}
|
||||
placeholder="수정이 필요한 내용을 구체적으로 작성해주세요" />
|
||||
</div>
|
||||
<button className="btn btn-danger" onClick={handleReject} disabled={saving} style={{ width: '100%', justifyContent: 'center' }}>
|
||||
{saving ? '처리 중...' : '✕ 반려하기'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
platform/frontend/src/pages/Admin/Users.jsx
Normal file
104
platform/frontend/src/pages/Admin/Users.jsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../../api/client';
|
||||
|
||||
export default function AdminUsers() {
|
||||
const [data, setData] = useState({ users: [], total: 0, pages: 1 });
|
||||
const [page, setPage] = useState(1);
|
||||
const [q, setQ] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
function load() {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page, limit: 30 });
|
||||
if (q) params.set('q', q);
|
||||
api.get(`/admin/users?${params}`)
|
||||
.then(r => setData(r.data))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, [page]);
|
||||
|
||||
async function toggleActive(userId) {
|
||||
await api.put(`/admin/users/${userId}/toggle`);
|
||||
load();
|
||||
}
|
||||
|
||||
async function changeRole(userId, role) {
|
||||
if (!confirm(`역할을 "${role}"로 변경하시겠습니까?`)) return;
|
||||
await api.put(`/admin/users/${userId}/role`, { role });
|
||||
load();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container page">
|
||||
<h2 style={{ marginBottom: 20 }}>사용자 관리</h2>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
||||
<input placeholder="이메일 또는 닉네임 검색" value={q}
|
||||
onChange={e => setQ(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { setPage(1); load(); } }}
|
||||
style={{ maxWidth: 280 }} />
|
||||
<button className="btn btn-outline" onClick={() => { setPage(1); load(); }}>검색</button>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="spinner" /> : (
|
||||
<>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>이메일</th><th>닉네임</th><th>역할</th><th>상태</th><th>가입일</th><th>최근 로그인</th><th>역할 변경</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td style={{ fontSize: 12 }}>{u.email}</td>
|
||||
<td>{u.nickname}</td>
|
||||
<td>
|
||||
<span className={`badge badge-${u.role === 'admin' ? 'approved' : u.role === 'seller' ? 'pending' : 'draft'}`}>
|
||||
{u.role}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ color: u.isActive ? 'var(--success)' : 'var(--danger)', fontSize: 12 }}>
|
||||
{u.isActive ? '활성' : '비활성'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-muted" style={{ fontSize: 12 }}>{new Date(u.createdAt).toLocaleDateString('ko-KR')}</td>
|
||||
<td className="text-muted" style={{ fontSize: 12 }}>
|
||||
{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleDateString('ko-KR') : '-'}
|
||||
</td>
|
||||
<td>
|
||||
<select defaultValue={u.role} onChange={e => changeRole(u.id, e.target.value)}
|
||||
style={{ width: 90, padding: '4px 8px', fontSize: 12 }}>
|
||||
<option value="buyer">buyer</option>
|
||||
<option value="seller">seller</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{u.role !== 'admin' && (
|
||||
<button className={`btn btn-sm ${u.isActive ? 'btn-danger' : 'btn-success'}`}
|
||||
onClick={() => toggleActive(u.id)}>
|
||||
{u.isActive ? '비활성화' : '활성화'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{data.pages > 1 && (
|
||||
<div className="pagination">
|
||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>이전</button>
|
||||
{Array.from({ length: data.pages }, (_, i) => (
|
||||
<button key={i + 1} className={page === i + 1 ? 'active' : ''} onClick={() => setPage(i + 1)}>{i + 1}</button>
|
||||
))}
|
||||
<button disabled={page >= data.pages} onClick={() => setPage(p => p + 1)}>다음</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user