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

@ -14,8 +14,6 @@ interface NavGroup {
key: string
path: string
label: string
badge?: string
badgeColor?: 'blue' | 'purple' | 'green' | 'orange'
icon: React.ReactElement
}>
}
@ -38,8 +36,6 @@ const NAV_GROUPS: NavGroup[] = [
key: 'pipelines',
path: '/pipelines',
label: 'AI & Voice Pipelines',
badge: 'v0.2',
badgeColor: 'blue',
icon: (
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 100-6 3 3 0 000 6z" />
@ -60,8 +56,6 @@ const NAV_GROUPS: NavGroup[] = [
key: 'releases',
path: '/releases',
label: 'Release & Downloads',
badge: 'v1.0',
badgeColor: 'green',
icon: (
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
@ -77,8 +71,6 @@ const NAV_GROUPS: NavGroup[] = [
key: 'users',
path: '/users',
label: 'User Directory',
badge: '4.5k',
badgeColor: 'purple',
icon: (
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
@ -99,8 +91,6 @@ const NAV_GROUPS: NavGroup[] = [
key: 'ads',
path: '/ads',
label: 'Ad Monetization',
badge: '$4.6k',
badgeColor: 'blue',
icon: (
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
@ -111,8 +101,6 @@ const NAV_GROUPS: NavGroup[] = [
key: 'support',
path: '/support',
label: 'Customer Support (CA)',
badge: '4 Live',
badgeColor: 'orange',
icon: (
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z" />
@ -148,7 +136,9 @@ const NAV_GROUPS: NavGroup[] = [
},
]
export function AdminSidebar(): React.ReactElement {
export function AdminSidebar({ identity }: {
identity: { email: string; role: 'manager' | 'admin' | 'super_admin' }
}): React.ReactElement {
const pathname = usePathname()
const router = useRouter()
@ -239,7 +229,7 @@ export function AdminSidebar(): React.ReactElement {
sx={{
fontFamily: FONT_SANS,
fontSize: '15px',
fontWeight: 700,
fontWeight: 500,
letterSpacing: '-0.02em',
color: C.bright,
lineHeight: 1.2,
@ -261,7 +251,7 @@ export function AdminSidebar(): React.ReactElement {
</Box>
</Box>
{/* Live Node Pulse */}
{/* Verified local session indicator */}
<Box
sx={{
display: 'flex',
@ -285,7 +275,7 @@ export function AdminSidebar(): React.ReactElement {
}}
/>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '9px', fontWeight: 600, color: '#34d399' }}>
LIVE
SESSION
</Typography>
</Box>
</Box>
@ -377,63 +367,12 @@ export function AdminSidebar(): React.ReactElement {
</Typography>
</Box>
{item.badge && (
<Box
sx={{
px: 0.9,
py: 0.2,
borderRadius: '999px',
fontSize: '10px',
fontFamily: FONT_MONO,
fontWeight: 700,
bgcolor: item.badgeColor === 'blue' ? 'rgba(59, 130, 246, 0.2)' : 'rgba(139, 92, 246, 0.2)',
color: item.badgeColor === 'blue' ? C.cyanLight : C.purple400,
border: `1px solid ${item.badgeColor === 'blue' ? 'rgba(59, 130, 246, 0.3)' : 'rgba(139, 92, 246, 0.3)'}`,
}}
>
{item.badge}
</Box>
)}
</Box>
)
})}
</Box>
</Box>
))}
{/* Live Service Matrix Mini-Widget */}
<Box
sx={{
mt: 1,
p: 2,
borderRadius: '14px',
bgcolor: 'rgba(13, 21, 38, 0.7)',
border: `1px solid ${C.border}`,
display: 'flex',
flexDirection: 'column',
gap: 1.2,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', fontWeight: 600, color: C.dim, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Service Telemetry
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: '#10b981' }}>
99.9% Up
</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1 }}>
<Box sx={{ p: 1, borderRadius: '8px', bgcolor: 'rgba(17, 26, 48, 0.6)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '9px', color: C.dim }}>STT LATENCY</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', fontWeight: 700, color: C.cyanLight }}>142ms</Typography>
</Box>
<Box sx={{ p: 1, borderRadius: '8px', bgcolor: 'rgba(17, 26, 48, 0.6)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '9px', color: C.dim }}>OLLAMA VRAM</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', fontWeight: 700, color: C.purple400 }}>4.6 GB</Typography>
</Box>
</Box>
</Box>
</Box>
{/* User Footer */}
@ -471,20 +410,20 @@ export function AdminSidebar(): React.ReactElement {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontWeight: 500,
fontSize: '13px',
color: '#ffffff',
boxShadow: '0 0 12px rgba(59, 130, 246, 0.4)',
}}
>
A
{identity.email.slice(0, 1).toUpperCase()}
</Box>
<Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', fontWeight: 600, color: C.bright, lineHeight: 1.2 }}>
Admin User
{identity.email}
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '9px', fontWeight: 600, color: C.purple400, letterSpacing: '0.04em' }}>
SUPER_ADMIN
{identity.role.toUpperCase()}
</Typography>
</Box>
</Box>
@ -516,4 +455,3 @@ export function AdminSidebar(): React.ReactElement {
</Box>
)
}

View file

@ -0,0 +1,49 @@
'use client'
import { useState } from 'react'
import { Box, Tooltip } from '@mui/material'
import { Check, Copy } from 'lucide-react'
import { C, FONT_MONO } from '@/lib/console-theme'
interface ChecksumCopyProps {
value: string
}
export function ChecksumCopy({ value }: ChecksumCopyProps): React.ReactElement {
const [copied, setCopied] = useState(false)
const handleCopy = (): void => {
void navigator.clipboard.writeText(value).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
return (
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
<Box component="span" sx={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{value}
</Box>
<Tooltip title={copied ? 'Copied' : 'Copy SHA-256'} placement="top">
<Box
component="button"
type="button"
onClick={handleCopy}
aria-label="Copy SHA-256 checksum"
sx={{
display: 'inline-flex',
alignItems: 'center',
p: 0.25,
border: 'none',
bgcolor: 'transparent',
cursor: 'pointer',
color: copied ? C.green400 : C.cyanLight,
'&:hover': { color: C.bright }
}}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
</Box>
</Tooltip>
</Box>
)
}

View file

@ -1,301 +0,0 @@
'use client'
// apps/admin/src/components/dashboard-simulator.tsx
// D3RO Voice — Interactive Realtime Audio & Intelligence Simulator Sandbox
import React, { useState } from 'react'
import { Box, Typography, Button, TextField } from '@mui/material'
import { C, FONT_SANS, FONT_MONO } from '@/lib/console-theme'
import { DoubleBezelCard, TactileBadge, StatRing } from '@d3ro/ui/components/ds'
export function DashboardSimulator(): React.ReactElement {
const [mode, setMode] = useState<'dictation' | 'meeting' | 'rag'>('dictation')
const [isRunning, setIsRunning] = useState(false)
const [step, setStep] = useState<number>(0)
const [interimText, setInterimText] = useState('')
const [finalResult, setFinalResult] = useState<Record<string, unknown> | null>(null)
const [searchQuery, setSearchQuery] = useState('프로젝트 출시 일정 및 마일스톤')
const steps = [
{ label: 'Audio Capture', desc: '16kHz Mono PCM Buffer' },
{ label: 'Whisper STT', desc: 'Faster-Whisper large-v3-turbo' },
{ label: 'LLM Orchestrator', desc: 'Ollama gemma4 / GPT-Realtime' },
{ label: 'Context / Export', desc: 'SQLite RAG / Multi-Doc' },
]
const runSimulation = () => {
setIsRunning(true)
setStep(1)
setInterimText('')
setFinalResult(null)
// Step 1: Audio buffer
setTimeout(() => {
setStep(2)
if (mode === 'dictation') {
setInterimText('오늘 회의에서 논의된 새로운 음성 인식 모델 성능에 대해서...')
} else if (mode === 'meeting') {
setInterimText('[화자 1]: 다음 주 스프린트 목표를 검토합시다. [화자 2]: STT 지연 시간을 140ms 이하로 줄였습니다.')
} else {
setInterimText('Query vector generated via nomic-embed-text (512-dim)...')
}
}, 600)
// Step 2: STT + interim stream
setTimeout(() => {
setStep(3)
if (mode === 'dictation') {
setInterimText('오늘 회의에서 논의된 새로운 음성 인식 모델 성능에 대해서 요약 및 문서화를 요청드립니다.')
}
}, 1300)
// Step 3: LLM & Final Result
setTimeout(() => {
setStep(4)
setIsRunning(false)
if (mode === 'dictation') {
setFinalResult({
status: 'success',
engine: 'Whisper large-v3-turbo + Ollama gemma4:e4b',
latencyMs: 142,
speedup: '6.2x',
originalText: '오늘 회의에서 논의된 새로운 음성 인식 모델 성능에 대해서 요약 및 문서화를 요청드립니다.',
polishedText: '금일 회의에서 논의된 신규 음성 인식 모델의 성능에 대한 요약 및 문서 작성을 요청드립니다.',
tokensUsed: 48,
costUsd: 0.0,
})
} else if (mode === 'meeting') {
setFinalResult({
status: 'success',
meetingTitle: 'D3RO Voice v0.2.1 릴리스 및 파이프라인 최적화 회의',
diarization: {
speaker1: '팀장 (45% 발화율)',
speaker2: 'ML 엔지니어 (55% 발화율)',
},
summary: 'Faster-Whisper turbo 사이드카 도입으로 지연 시간을 142ms로 6배 단축하였으며, Pyannote 화자 분리 정확도 96.4%를 달성함.',
actionItems: [
'1. Windows 및 macOS 배포 패키지 무결성 검증 완료',
'2. SQLite 벡터 RAG 인덱스 4.8k 문서 동기화',
],
generatedDocs: ['Executive Summary', 'Action Item Checklist', 'Mindmap Diagram'],
})
} else {
setFinalResult({
status: 'success',
query: searchQuery,
embeddingLatencyMs: 18.4,
vectorMatches: [
{ docId: 'DOC_4821', title: '2026 Q3 D3RO Voice 로드맵.md', similarity: 0.942, excerpt: 'Phase 15.5 화자 분리 및 실시간 회의 모드 8월 말 정식 출시...' },
{ docId: 'DOC_3102', title: 'Whisper_Turbo_사이드카_아키텍처.md', similarity: 0.887, excerpt: 'dual-condition parallel flush 패턴을 적용하여 버퍼 지연 최소화...' },
],
})
}
}, 2100)
}
return (
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'space-between', alignItems: 'center', mb: 3, gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<StatRing color="blue" size={44}>
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</StatRing>
<Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
Live Voice & AI Intelligence Sandbox
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
SIMULATE VOICE CAPTURE • INTERIM STT • AUTO-POLISH • VECTOR RAG
</Typography>
</Box>
</Box>
{/* Mode Selector Tabs */}
<Box sx={{ display: 'flex', gap: 1, bgcolor: 'rgba(10, 17, 31, 0.8)', p: 0.5, borderRadius: '999px', border: `1px solid ${C.border}` }}>
{(['dictation', 'meeting', 'rag'] as const).map((m) => (
<Button
key={m}
size="small"
onClick={() => { setMode(m); setFinalResult(null); setInterimText('') }}
sx={{
borderRadius: '999px',
px: 2,
py: 0.4,
fontSize: '11px',
fontFamily: FONT_SANS,
fontWeight: mode === m ? 700 : 500,
textTransform: 'uppercase',
letterSpacing: '0.04em',
bgcolor: mode === m ? 'rgba(59, 130, 246, 0.25)' : 'transparent',
color: mode === m ? C.bright : C.dim,
border: mode === m ? '1px solid rgba(96, 165, 250, 0.4)' : '1px solid transparent',
'&:hover': { bgcolor: 'rgba(59, 130, 246, 0.15)', color: C.bright },
}}
>
{m === 'dictation' ? '🎤 Dictation' : m === 'meeting' ? '👥 Meeting Mode' : '🔍 Vector RAG'}
</Button>
))}
</Box>
</Box>
{/* Pipeline Progress Stages */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1.5, mb: 3 }}>
{steps.map((s, idx) => {
const stepNum = idx + 1
const isActive = step === stepNum
const isDone = step > stepNum
return (
<Box
key={s.label}
sx={{
p: 1.5,
borderRadius: '12px',
bgcolor: isActive ? 'rgba(59, 130, 246, 0.18)' : isDone ? 'rgba(16, 185, 129, 0.12)' : 'rgba(10, 17, 31, 0.6)',
border: `1px solid ${isActive ? C.accentLight : isDone ? 'rgba(16, 185, 129, 0.3)' : C.border}`,
transition: 'all 0.2s ease',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: isActive ? C.cyanLight : isDone ? C.green400 : C.dim }}>
STAGE 0{stepNum}
</Typography>
{isDone ? (
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.green400 }}>✓ DONE</Typography>
) : isActive ? (
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.cyanLight, animation: 'pulse-ring 1s infinite' }}>● ACTIVE</Typography>
) : (
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.muted }}>READY</Typography>
)}
</Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', fontWeight: 600, color: C.bright }}>
{s.label}
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>
{s.desc}
</Typography>
</Box>
)
})}
</Box>
{/* Interactive Trigger Bar */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
{mode === 'rag' ? (
<TextField
fullWidth
size="small"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search vectorized knowledge documents..."
sx={{
'& .MuiOutlinedInput-root': {
bgcolor: 'rgba(10, 17, 31, 0.7)',
color: C.bright,
borderRadius: '10px',
fontFamily: FONT_SANS,
fontSize: '13px',
'& fieldset': { borderColor: C.border },
'&:hover fieldset': { borderColor: C.borderHl },
'&.Mui-focused fieldset': { borderColor: C.accentLight },
},
}}
/>
) : (
<Box
sx={{
flex: 1,
p: 1.5,
borderRadius: '10px',
bgcolor: 'rgba(10, 17, 31, 0.7)',
border: `1px solid ${C.border}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: interimText ? C.cyanLight : C.dim }}>
{interimText || 'Waiting for voice audio input stream...'}
</Typography>
{isRunning && (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
{[12, 24, 18, 28, 14, 20, 32, 16].map((h, i) => (
<Box
key={i}
sx={{
width: 3,
height: h,
bgcolor: C.cyanLight,
borderRadius: '2px',
animation: `pulse-ring ${0.4 + i * 0.1}s ease-in-out infinite alternate`,
}}
/>
))}
</Box>
)}
</Box>
)}
<Button
variant="contained"
disabled={isRunning}
onClick={runSimulation}
sx={{
background: 'linear-gradient(135deg, #1d4ed8 0%, #3b82f6 55%, #60a5fa 100%)',
color: '#ffffff',
px: 3,
py: 1.1,
borderRadius: '10px',
fontFamily: FONT_SANS,
fontSize: '13px',
fontWeight: 700,
textTransform: 'none',
flexShrink: 0,
boxShadow: '0 0 20px rgba(59, 130, 246, 0.4)',
'&:hover': { filter: 'brightness(1.15)' },
}}
>
{isRunning ? 'Processing...' : '▶ Run Live Test'}
</Button>
</Box>
{/* Output Results Box */}
{finalResult && (
<Box
sx={{
p: 2.5,
borderRadius: '14px',
bgcolor: 'rgba(10, 17, 31, 0.85)',
border: `1px solid rgba(59, 130, 246, 0.3)`,
boxShadow: 'inset 0 2px 6px rgba(3, 7, 18, 0.7)',
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', fontWeight: 600, color: C.green400 }}>
PIPELINE EXECUTION TELEMETRY RESULT
</Typography>
<TactileBadge tone="success" mono>
SUCCESS (200 OK)
</TactileBadge>
</Box>
<Box
component="pre"
sx={{
m: 0,
fontFamily: FONT_MONO,
fontSize: '12px',
color: C.bright,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
lineHeight: 1.6,
}}
>
{JSON.stringify(finalResult, null, 2)}
</Box>
</Box>
)}
</DoubleBezelCard>
)
}

View file

@ -1,11 +1,11 @@
'use client'
// apps/admin/src/components/license-issuer-button.tsx
// D3RO Voice Admin — Ed25519 라이선스 발급 트리거 버튼
// D3RO Voice Admin — Ed25519 라이선스 발급 트리거 버튼 (super_admin 전용)
import { useState } from 'react'
import { Button } from '@mui/material'
import { primaryButtonSx } from '@/lib/console-theme'
import { C, primaryButtonSx } from '@/lib/console-theme'
import { LicenseIssuerDialog } from './license-issuer-dialog'
export function LicenseIssuerButton(): React.ReactElement {
@ -19,8 +19,8 @@ export function LicenseIssuerButton(): React.ReactElement {
onClick={() => setOpen(true)}
sx={{
...primaryButtonSx,
background: 'linear-gradient(135deg, #a855f7 0%, #3b82f6 100%)',
boxShadow: '0 0 12px rgba(168, 85, 247, 0.4)',
background: `linear-gradient(135deg, ${C.purple} 0%, ${C.accent} 100%)`,
boxShadow: '0 0 12px rgba(139, 92, 246, 0.4)'
}}
>
⚡ Issue Ed25519 License Key

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'}

View file

@ -4,7 +4,7 @@
// 결제 이력 패널 — DB + Payple 조회
import { useState, useEffect } from 'react'
import { Box, Button, CircularProgress } from '@mui/material'
import { Box, CircularProgress } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { callAdminApi } from '@/lib/admin-api'
@ -25,22 +25,23 @@ interface AuditLogEntry {
interface PaymentData {
subscription: Record<string, unknown> | null
auditLogs: AuditLogEntry[]
paypleHistory?: Record<string, unknown>
paypleError?: string
providerEvents: Array<Record<string, unknown>>
providerOperations: Array<Record<string, unknown>>
liveProviderHistoryAvailable: false
}
export function PaymentHistory({ userId }: PaymentHistoryProps): React.ReactElement {
const [data, setData] = useState<PaymentData | null>(null)
const [loading, setLoading] = useState(true)
const [paypleLoading, setPaypleLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const load = async (): Promise<void> => {
try {
const result = await callAdminApi<PaymentData>(`admin-payments?userId=${userId}`)
setData(result)
} catch {
// ignore
} catch (caught) {
setError(caught instanceof Error ? caught.message : 'Failed to load payment data')
} finally {
setLoading(false)
}
@ -48,18 +49,6 @@ export function PaymentHistory({ userId }: PaymentHistoryProps): React.ReactElem
void load()
}, [userId])
const loadPayple = async (): Promise<void> => {
setPaypleLoading(true)
try {
const result = await callAdminApi<PaymentData>(`admin-payments?userId=${userId}&source=payple`)
setData(result)
} catch {
// ignore
} finally {
setPaypleLoading(false)
}
}
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
@ -69,55 +58,56 @@ export function PaymentHistory({ userId }: PaymentHistoryProps): React.ReactElem
}
if (!data) {
return <PhosphorText variant="dim">Failed to load payment data</PhosphorText>
return <PhosphorText variant="dim" sx={{ color: d3roPalette.tag.red }}>{error ?? 'Failed to load payment data'}</PhosphorText>
}
const sub = data.subscription
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{error && <PhosphorText variant="dim" sx={{ color: d3roPalette.tag.red }}>{error}</PhosphorText>}
{/* Subscription summary */}
{sub && (
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PAYMENT INFO</PhosphorText>
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Row label="PROVIDER" value={((sub.payment_provider as string) ?? 'none').toUpperCase()} />
<Row label="PAYPLE PAYER ID" value={(sub.payple_payer_id as string) ?? '-'} />
<Row label="PAYPLE OID" value={(sub.payple_pay_oid as string) ?? '-'} />
<Row label="RENEWAL FAILURES" value={String(sub.renewal_failures ?? 0)} />
<Row label="PROVIDER" value={String(sub.provider ?? sub.payment_provider).toUpperCase()} />
<Row label="TIER" value={String(sub.tier).toUpperCase()} />
<Row label="STATUS" value={String(sub.status).toUpperCase()} />
<Row label="CURRENT PERIOD END" value={typeof sub.current_period_end === 'string' ? new Date(sub.current_period_end).toLocaleString() : 'Not set'} />
</Box>
</Box>
</MetalCard>
)}
{/* Payple direct query */}
{/* Provider ledger. Provider payloads and secret identifiers are intentionally excluded. */}
<MetalCard>
<Box sx={{ p: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<PhosphorText variant="label">PAYPLE HISTORY</PhosphorText>
<Button
size="small"
onClick={() => void loadPayple()}
disabled={paypleLoading}
sx={{ fontFamily: d3roFontMono, fontSize: 11 }}
>
{paypleLoading ? 'Loading...' : 'Fetch from Payple'}
</Button>
</Box>
{data.paypleHistory ? (
<Box sx={{
fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
bgcolor: d3roPalette.bg.inset, borderRadius: 1, p: 1, maxHeight: 300, overflow: 'auto',
whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: d3roPalette.text.primary,
}}>
{JSON.stringify(data.paypleHistory, null, 2)}
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PAYMENT PROVIDER EVENT LEDGER</PhosphorText>
{data.providerEvents.length === 0 ? (
<PhosphorText variant="dim">No provider events recorded</PhosphorText>
) : data.providerEvents.map((event) => (
<Box key={String(event.id)} sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, py: 0.75, borderBottom: `1px solid ${d3roPalette.border.subtle}` }}>
{String(event.provider).toUpperCase()} · {String(event.event_type)} · {String(event.disposition).toUpperCase()} · {new Date(String(event.event_created_at)).toLocaleString()}
</Box>
) : data.paypleError ? (
<PhosphorText variant="dim" sx={{ color: d3roPalette.tag.red }}>{data.paypleError}</PhosphorText>
) : (
<PhosphorText variant="dim">Click &quot;Fetch from Payple&quot; to query payment history</PhosphorText>
)}
))}
<PhosphorText variant="dim" sx={{ display: 'block', mt: 1 }}>
Live provider lookup is not connected. Raw Payple responses and provider identifiers are never exposed here.
</PhosphorText>
</Box>
</MetalCard>
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PAYMENT OPERATIONS</PhosphorText>
{data.providerOperations.length === 0 ? (
<PhosphorText variant="dim">No payment operations recorded</PhosphorText>
) : data.providerOperations.map((operation) => (
<Box key={String(operation.id)} sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, py: 0.75, borderBottom: `1px solid ${d3roPalette.border.subtle}` }}>
{String(operation.provider).toUpperCase()} · {String(operation.operation_type)} · {String(operation.state).toUpperCase()} · {new Date(String(operation.created_at)).toLocaleString()}
</Box>
))}
</Box>
</MetalCard>

View file

@ -77,8 +77,10 @@ export function RoleChangeDialog({
</PhosphorText>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.label }}>New Role</InputLabel>
<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"

View file

@ -18,7 +18,7 @@ import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
import { callAdminApi } from '@/lib/admin-api'
type Tier = 'free' | 'pro' | 'pro_plus' | 'team' | 'enterprise'
type Tier = 'free' | 'pro' | 'pro_plus'
type SubStatus = 'active' | 'canceled' | 'past_due' | 'expired'
interface SubscriptionFormProps {
@ -104,19 +104,17 @@ export function SubscriptionForm({ mode, userId, initial, onSuccess }: Subscript
</PhosphorText>
<FormControl fullWidth sx={inputSx}>
<InputLabel sx={{ fontFamily: d3roFontMono }}>Tier</InputLabel>
<Select value={tier} onChange={(e) => setTier(e.target.value as Tier)} label="Tier">
<InputLabel id="subscription-tier-label" sx={{ fontFamily: d3roFontMono }}>Tier</InputLabel>
<Select id="subscription-tier" labelId="subscription-tier-label" value={tier} onChange={(e) => setTier(e.target.value as Tier)} label="Tier">
<MenuItem value="free">FREE</MenuItem>
<MenuItem value="pro">PRO</MenuItem>
<MenuItem value="pro_plus">PRO+</MenuItem>
<MenuItem value="team">TEAM</MenuItem>
<MenuItem value="enterprise">ENTERPRISE</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth sx={inputSx}>
<InputLabel sx={{ fontFamily: d3roFontMono }}>Status</InputLabel>
<Select value={status} onChange={(e) => setStatus(e.target.value as SubStatus)} label="Status">
<InputLabel id="subscription-status-label" sx={{ fontFamily: d3roFontMono }}>Status</InputLabel>
<Select id="subscription-status" labelId="subscription-status-label" value={status} onChange={(e) => setStatus(e.target.value as SubStatus)} label="Status">
<MenuItem value="active">ACTIVE</MenuItem>
<MenuItem value="canceled">CANCELED</MenuItem>
<MenuItem value="past_due">PAST DUE</MenuItem>

View file

@ -0,0 +1,37 @@
import { Alert, Box, Typography } from '@mui/material'
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
import { C, FONT_MONO, FONT_SANS, panelSx } from '@/lib/console-theme'
interface UnavailableAdminPanelProps {
title: string
capability: string
reason: string
}
export function UnavailableAdminPanel({ title, capability, reason }: UnavailableAdminPanelProps): React.ReactElement {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box sx={{ ...panelSx, minHeight: 84, px: { xs: 2.5, md: 4 }, py: 2, display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ width: 4, height: 32, borderRadius: '999px', bgcolor: C.orange400 }} />
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography component="h1" sx={{ fontFamily: FONT_SANS, fontSize: '20px', fontWeight: 500, color: C.bright }}>
{title}
</Typography>
<TactileBadge tone="warning" mono>NOT CONNECTED</TactileBadge>
</Box>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, mt: 0.25 }}>
{capability}
</Typography>
</Box>
</Box>
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Alert severity="warning">
<Typography sx={{ fontWeight: 500, mb: 0.5 }}>실제 관리 계약이 아직 연결되지 않았습니다.</Typography>
<Typography>{reason}</Typography>
<Typography sx={{ mt: 1 }}>샘플 수치나 성공 상태는 표시하지 않으며, 쓰기 제어도 비활성화했습니다.</Typography>
</Alert>
</DoubleBezelCard>
</Box>
)
}