feat(admin): Phase V2-6 Admin CRM 고도화 — CRUD + 차트 + 결제 + 감사

- DB: audit_log 테이블(diff 포함) + subscriptions.admin_note + super_admin role
- Edge Functions 4개: admin-users, admin-subscriptions, admin-payments, admin-audit-log
- 공유 유틸: admin-auth.ts(권한 검증), audit.ts(감사로그 기록)
- Swagger UI: 독립 정적 페이지 + OpenAPI 3.0 spec
- CRUD 페이지: 구독 생성/수정/삭제, role 변경, 감사로그 목록/상세
- recharts: feature별 StackedBar + DAU Line + Top Users HorizontalBar
- 결제 이력: DB + Payple API 병행 조회
- 권한: super_admin만 위험 작업, admin은 조회 전용
- RLS: admin/super_admin IN 정책 + super_admin 쓰기 정책
- SQL RPC: admin_usage_by_feature, admin_top_users, admin_dau
This commit is contained in:
윤찬 2026-04-12 21:32:47 +09:00
parent f7c50eb2ed
commit dca1b90faa
34 changed files with 3142 additions and 45 deletions

View file

@ -0,0 +1,130 @@
'use client'
// apps/admin/src/components/role-change-dialog.tsx
// role 변경 확인 다이얼로그 — super_admin 전용
import { useState } from 'react'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
FormControl,
InputLabel,
Select,
MenuItem,
} from '@mui/material'
import { PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
import { callAdminApi } from '@/lib/admin-api'
type Role = 'user' | 'admin' | 'super_admin'
interface RoleChangeDialogProps {
open: boolean
userId: string
userName: string | null
currentRole: Role
onClose: () => void
onSuccess: () => void
}
export function RoleChangeDialog({
open, userId, userName, currentRole, onClose, onSuccess,
}: RoleChangeDialogProps): React.ReactElement {
const [newRole, setNewRole] = useState<Role>(currentRole)
const [memo, setMemo] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleConfirm = async (): Promise<void> => {
if (!memo.trim() || newRole === currentRole) return
setLoading(true)
setError(null)
try {
await callAdminApi('admin-users', {
method: 'PATCH',
body: JSON.stringify({ userId, newRole, memo: memo.trim() }),
})
setMemo('')
onSuccess()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to change role')
} finally {
setLoading(false)
}
}
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{ sx: { bgcolor: d3roPalette.bg.card, color: d3roPalette.text.primary } }}
>
<DialogTitle sx={{ fontFamily: d3roFontMono }}>
<PhosphorText variant="heading">CHANGE USER ROLE</PhosphorText>
</DialogTitle>
<DialogContent>
<PhosphorText variant="dim" sx={{ display: 'block', mb: 2 }}>
{userName ?? userId.substring(0, 8)} : {currentRole}
</PhosphorText>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.label }}>New Role</InputLabel>
<Select
value={newRole}
onChange={(e) => setNewRole(e.target.value as Role)}
label="New Role"
sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.primary }}
>
<MenuItem value="user">user</MenuItem>
<MenuItem value="admin">admin</MenuItem>
<MenuItem value="super_admin">super_admin</MenuItem>
</Select>
</FormControl>
<TextField
fullWidth
multiline
rows={3}
placeholder="Reason for role change (required)..."
value={memo}
onChange={(e) => setMemo(e.target.value)}
sx={{
'& .MuiInputBase-root': {
fontFamily: d3roFontMono,
fontSize: 13,
color: d3roPalette.text.primary,
bgcolor: d3roPalette.bg.inset,
},
}}
/>
{error && (
<PhosphorText variant="dim" sx={{ display: 'block', mt: 1, color: d3roPalette.tag.red }}>
{error}
</PhosphorText>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={onClose} sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.secondary }}>
Cancel
</Button>
<Button
onClick={() => void handleConfirm()}
disabled={!memo.trim() || newRole === currentRole || loading}
variant="contained"
color={newRole === 'super_admin' ? 'error' : 'primary'}
sx={{ fontFamily: d3roFontMono }}
>
{loading ? 'Changing...' : 'Change Role'}
</Button>
</DialogActions>
</Dialog>
)
}