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:
parent
a9c9a1ca6e
commit
5a34f66981
66 changed files with 4471 additions and 3501 deletions
|
|
@ -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'}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue