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
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
49
apps/admin/src/components/checksum-copy.tsx
Normal file
49
apps/admin/src/components/checksum-copy.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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 "Fetch from Payple" 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
37
apps/admin/src/components/unavailable-admin-panel.tsx
Normal file
37
apps/admin/src/components/unavailable-admin-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue