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
This commit is contained in:
Yun Chan 2026-08-23 23:38:08 +09:00
parent a9c9a1ca6e
commit 5a34f66981
66 changed files with 4471 additions and 3501 deletions

View file

@ -1,7 +1,7 @@
'use client'
// apps/admin/src/components/license-issuer-dialog.tsx
// D3RO Voice Admin — Ed25519 비대칭 암호화 라이선스 발급 다이얼로그
// Ed25519 라이선스 발급 다이얼로그 — 서명은 서버(/api/admin/license)에서만 수행한다.
import { useState } from 'react'
import {
@ -17,12 +17,8 @@ import {
Select,
MenuItem,
Button,
Alert,
Alert
} from '@mui/material'
import {
issueSignedLicenseKey,
DEFAULT_LICENSE_PRIVATE_KEY,
} from '@d3ro/core/utils/crypto-license'
import type { LicenseTier } from '@d3ro/core/types'
import { d3roPalette, d3roFontMono, d3roRadius } from '@d3ro/ui/theme'
import { C, FONT_SANS, FONT_MONO, primaryButtonSx } from '@/lib/console-theme'
@ -32,6 +28,16 @@ interface LicenseIssuerDialogProps {
onClose: () => void
}
function describeIssueError(errorKey: unknown): string {
if (errorKey === 'license_signing_unavailable') {
return '서명 키가 구성되지 않았습니다. ADMIN_LICENSE_PRIVATE_KEY 환경변수를 설정해주세요.'
}
if (errorKey === 'admin_forbidden') return 'super_admin 권한이 필요한 작업입니다.'
if (errorKey === 'admin_session_invalid') return '세션이 만료되었습니다. 다시 로그인해주세요.'
if (errorKey === 'invalid_customer_email') return '고객 이메일 형식이 올바르지 않습니다.'
return '라이선스 발급에 실패했습니다.'
}
export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps): React.ReactElement {
const [customerEmail, setCustomerEmail] = useState('')
const [tier, setTier] = useState<LicenseTier>('pro_plus')
@ -39,10 +45,13 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
const [machineId, setMachineId] = useState('')
const [teamId, setTeamId] = useState('')
const [generatedKey, setGeneratedKey] = useState<string | null>(null)
const [usedDefaultKey, setUsedDefaultKey] = useState(false)
const [auditRecorded, setAuditRecorded] = useState(true)
const [loading, setLoading] = useState(false)
const [copied, setCopied] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleGenerate = () => {
const handleGenerate = async (): Promise<void> => {
setError(null)
setCopied(false)
if (!customerEmail.trim()) {
@ -50,36 +59,36 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
return
}
setLoading(true)
try {
const now = Date.now()
let expiresAt: number | null = null
if (validity === '30d') {
expiresAt = now + 30 * 24 * 60 * 60 * 1000
} else if (validity === '365d') {
expiresAt = now + 365 * 24 * 60 * 60 * 1000
const response = await fetch('/api/admin/license', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customerEmail: customerEmail.trim(),
tier,
validity,
machineId: machineId.trim(),
teamId: teamId.trim()
})
})
const data = (await response.json()) as Record<string, unknown>
if (!response.ok || data.success !== true || typeof data.licenseKey !== 'string') {
throw new Error(describeIssueError(data.error))
}
const payload = {
licenseId: `lic-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 6)}`,
tier,
customerEmail: customerEmail.trim(),
issuedAt: now,
expiresAt,
machineId: machineId.trim() || null,
teamId: teamId.trim() || undefined,
maxDevices: tier === 'enterprise' ? 999 : tier === 'team' ? 25 : tier === 'pro_plus' ? 5 : 3,
}
const key = issueSignedLicenseKey(payload, DEFAULT_LICENSE_PRIVATE_KEY)
setGeneratedKey(key)
setGeneratedKey(data.licenseKey)
setUsedDefaultKey(data.usedDefaultKey === true)
setAuditRecorded(data.auditRecorded !== false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate license key')
} finally {
setLoading(false)
}
}
const handleCopy = () => {
const handleCopy = (): void => {
if (!generatedKey) return
navigator.clipboard.writeText(generatedKey)
void navigator.clipboard.writeText(generatedKey)
setCopied(true)
setTimeout(() => setCopied(false), 3000)
}
@ -90,13 +99,13 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
fontSize: 13,
color: d3roPalette.text.primary,
bgcolor: d3roPalette.bg.inset,
borderRadius: d3roRadius.button,
borderRadius: d3roRadius.button
},
'& .MuiInputLabel-root': {
fontFamily: FONT_MONO,
fontSize: 12,
color: C.dim,
},
color: C.dim
}
}
return (
@ -107,21 +116,21 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
fullWidth
PaperProps={{
sx: {
bgcolor: '#0a0d14',
border: '1px solid rgba(255, 255, 255, 0.1)',
bgcolor: C.app,
border: `1px solid ${C.borderHl}`,
borderRadius: '16px',
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.8)',
p: 1,
},
p: 1
}
}}
>
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', pb: 1 }}>
<Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '18px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '18px', fontWeight: 600, color: C.bright }}>
Issue Cryptographic License Key
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
ED25519 ASYMMETRIC SIGNED OFFLINE / ENTERPRISE TOKEN
ED25519 SERVER-SIGNED OFFLINE / ENTERPRISE TOKEN
</Typography>
</Box>
<IconButton onClick={onClose} size="small" sx={{ color: C.dim }}>
@ -152,7 +161,11 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
<FormControl fullWidth sx={inputSx}>
<InputLabel>Validity Period</InputLabel>
<Select value={validity} onChange={(e) => setValidity(e.target.value as '30d' | '365d' | 'lifetime')} label="Validity Period">
<Select
value={validity}
onChange={(e) => setValidity(e.target.value as '30d' | '365d' | 'lifetime')}
label="Validity Period"
>
<MenuItem value="30d">30 Days (Monthly)</MenuItem>
<MenuItem value="365d">1 Year (Annual)</MenuItem>
<MenuItem value="lifetime">Lifetime (Permanent)</MenuItem>
@ -184,20 +197,32 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
<Button
variant="contained"
onClick={handleGenerate}
onClick={() => void handleGenerate()}
disabled={loading}
sx={{
...primaryButtonSx,
py: 1.2,
background: 'linear-gradient(135deg, #a855f7 0%, #3b82f6 100%)',
fontWeight: 700,
background: `linear-gradient(135deg, ${C.purple} 0%, ${C.accent} 100%)`,
fontWeight: 600
}}
>
⚡ Generate Ed25519 Signed License
{loading ? 'Signing…' : '⚡ Generate Ed25519 Signed License'}
</Button>
{generatedKey && (
<Box sx={{ mt: 1, p: 2, bgcolor: 'rgba(0, 0, 0, 0.4)', borderRadius: '10px', border: '1px solid rgba(168, 85, 247, 0.4)' }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: '#a855f7', fontWeight: 700, mb: 0.5 }}>
<Box sx={{ mt: 1, p: 2, bgcolor: 'rgba(0, 0, 0, 0.4)', borderRadius: '10px', border: `1px solid ${C.borderStrong}` }}>
{usedDefaultKey && (
<Alert severity="warning" sx={{ fontFamily: FONT_MONO, fontSize: '11px', mb: 1.5 }}>
저장소 기본 키로 서명되었습니다. 기본 키쌍은 공개되어 위조 방어력이 없으므로 운영 배포 전
ADMIN_LICENSE_PRIVATE_KEY로 키를 로테이션하세요.
</Alert>
)}
{!auditRecorded && (
<Alert severity="info" sx={{ fontFamily: FONT_MONO, fontSize: '11px', mb: 1.5 }}>
라이선스는 발급되었으나 감사 로그 기록에 실패했습니다. 백엔드 연결을 확인하세요.
</Alert>
)}
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.purple400, fontWeight: 600, mb: 0.5 }}>
SIGNED LICENSE KEY (Copy and paste into D3RO Voice Desktop App):
</Typography>
<Typography
@ -210,7 +235,7 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
borderRadius: '6px',
wordBreak: 'break-all',
userSelect: 'all',
mb: 1.5,
mb: 1.5
}}
>
{generatedKey}
@ -222,8 +247,8 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
sx={{
fontFamily: FONT_MONO,
fontSize: '12px',
borderColor: copied ? '#10b981' : '#a855f7',
color: copied ? '#10b981' : C.bright,
borderColor: copied ? C.green : C.purple400,
color: copied ? C.green400 : C.bright
}}
>
{copied ? '✓ Copied to Clipboard!' : '📋 Copy License Key'}