예전 배포본(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
135 lines
4.2 KiB
TypeScript
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>
|
|
)
|
|
}
|