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