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:
parent
f7c50eb2ed
commit
dca1b90faa
34 changed files with 3142 additions and 45 deletions
130
apps/admin/src/components/role-change-dialog.tsx
Normal file
130
apps/admin/src/components/role-change-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue