d3ro-voice/apps/admin/src/components/role-change-dialog.tsx
Yun Chan 5a34f66981 feat(admin): 예전/최신 어드민 통합 — 실데이터 복원 + 인증 아키텍처 정리
예전 배포본(HEAD)의 상세 기능을 새 아키텍처(인증=.NET 백엔드, 데이터=Supabase)
위에 실데이터로 복원. 예전 HEAD는 서명 쿠키를 거부하고 위조 쿠키는 통과시키는
인증 결함이 있었고, 삭제된 페이지 다수는 백엔드 호출 0인 하드코딩 목업이었음.

인증/세션
- 로그인 이메일 전용화(username 폐지), 에러 키별 안내 메시지
- ADMIN_COOKIE_SECURE 옵션: TLS 없는 LAN HTTP 배포에서 Secure 쿠키 유실로
  로그인이 유지되지 않던 문제 해결 (login/logout route, admin-session, compose, .env.example)
- Supabase 미설정 시 우아한 저하: isSupabaseAdminConfigured + UnavailableAdminPanel

기능 복원 (실데이터)
- Release Hub: Forgejo API 실데이터(다운로드 수/SHA-256 체크섬/릴리스 이력)
- Ad Monetization: 데스크톱 미디에이션 10개 어댑터 로스터(fail-closed) + ad_reward_claims 통계
- License Issuer: 서버사이드 Ed25519 서명(/api/admin/license, super_admin 전용),
  개인키는 ADMIN_LICENSE_PRIVATE_KEY env로만, 발급 감사를 .NET AdminAuditEntries에 기록
- Service Models: STT 7종/LLM 5종 프리셋 드롭다운 + 자동채움
- 대시보드 ARR/MRR KPI: Supabase 구독 실집계(티어 월단가 기반)
- 사용자 상세 티어별 기능 배지(pro_plus 조건부)

.NET
- SuperAdminOnly 정책 추가, /api/admin/license-audit 엔드포인트, LicenseAuditDto
2026-08-23 23:38:08 +09:00

135 lines
4.2 KiB
TypeScript

'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' | 'manager' | 'admin' | 'super_admin'
interface RoleChangeDialogProps {
open: boolean
userId: string
userName: string | null
currentRole: Role
/** 현재 로그인한 admin의 role */
callerRole?: Role
onClose: () => void
onSuccess: () => void
}
export function RoleChangeDialog({
open, userId, userName, currentRole, callerRole = 'admin', 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 id="role-change-new-role-label" sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.label }}>New Role</InputLabel>
<Select
id="role-change-new-role"
labelId="role-change-new-role-label"
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="manager">manager</MenuItem>
{callerRole === 'super_admin' && <MenuItem value="admin">admin</MenuItem>}
{callerRole === 'super_admin' && <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>
)
}