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,562 +1,280 @@
|
|||
// apps/admin/src/app/(admin)/ads/page.tsx
|
||||
// D3RO Voice — Multi-Ad Network Mediation & Revenue Settlement Console (10+ Demand Sources)
|
||||
// D3RO Voice Admin CRM — Ad Mediation & Monetization Console
|
||||
|
||||
import React from 'react'
|
||||
import { Box, Typography, Button } from '@mui/material'
|
||||
import {
|
||||
C,
|
||||
FONT_SANS,
|
||||
FONT_MONO,
|
||||
panelSx,
|
||||
tableSx,
|
||||
statusBadgeSx,
|
||||
primaryButtonSx,
|
||||
} from '@/lib/console-theme'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
|
||||
import { requireManager } from '@/lib/admin-guard'
|
||||
import { MEDIATION_ROSTER, fetchAdRewardStats, type AdRewardStats } from '@/lib/ad-monetization'
|
||||
import { isSupabaseAdminConfigured } from '@/lib/supabase-admin'
|
||||
|
||||
interface AdNetworkStat {
|
||||
id: string
|
||||
name: string
|
||||
adapterType: string
|
||||
format: string
|
||||
impressions: number
|
||||
clicks: number
|
||||
ctr: string
|
||||
ecpm: number
|
||||
grossRevenueUsd: number
|
||||
fillRate: string
|
||||
status: 'active' | 'bidding' | 'fallback'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const FORMAT_LABEL: Record<string, string> = {
|
||||
banner_dock: 'Bottom Dock',
|
||||
rewarded_video: 'Rewarded Video',
|
||||
export_sponsor: 'Export Sponsor',
|
||||
audio_chime: 'Audio Chime'
|
||||
}
|
||||
|
||||
interface SettlementRow {
|
||||
id: string
|
||||
cycleMonth: string
|
||||
networkName: string
|
||||
grossUsd: number
|
||||
withholdingTax: string
|
||||
netPayoutKrw: number
|
||||
payoutStatus: 'settled' | 'paid' | 'pending'
|
||||
method: string
|
||||
function formatDateTime(value: string): string {
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? '—' : parsed.toISOString().replace('T', ' ').slice(0, 16)
|
||||
}
|
||||
|
||||
export default async function AdminAdsPage(): Promise<React.ReactElement> {
|
||||
// 10+ Production Ad Networks Active in D3RO Voice Mediation
|
||||
const networks: AdNetworkStat[] = [
|
||||
{
|
||||
id: 'net_001',
|
||||
name: 'Direct House Sponsor Engine',
|
||||
adapterType: 'Direct Contract',
|
||||
format: 'Bottom Dock / Video / Export',
|
||||
impressions: 84000,
|
||||
clicks: 4200,
|
||||
ctr: '5.0%',
|
||||
ecpm: 15.2,
|
||||
grossRevenueUsd: 1276.8,
|
||||
fillRate: '100.0%',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'net_002',
|
||||
name: 'Playwire RAMP Engine',
|
||||
adapterType: 'Header Bidding SSP',
|
||||
format: 'Desktop Video & Display',
|
||||
impressions: 62000,
|
||||
clicks: 4340,
|
||||
ctr: '7.0%',
|
||||
ecpm: 8.4,
|
||||
grossRevenueUsd: 520.8,
|
||||
fillRate: '96.2%',
|
||||
status: 'bidding',
|
||||
},
|
||||
{
|
||||
id: 'net_003',
|
||||
name: 'AppLovin MAX',
|
||||
adapterType: 'Real-Time In-App Bidding',
|
||||
format: 'Rewarded Video (15s)',
|
||||
impressions: 48000,
|
||||
clicks: 3840,
|
||||
ctr: '8.0%',
|
||||
ecpm: 7.8,
|
||||
grossRevenueUsd: 374.4,
|
||||
fillRate: '94.5%',
|
||||
status: 'bidding',
|
||||
},
|
||||
{
|
||||
id: 'net_004',
|
||||
name: 'Unity LevelPlay',
|
||||
adapterType: 'Rewarded Video SDK',
|
||||
format: 'Rewarded Quota Refill',
|
||||
impressions: 45000,
|
||||
clicks: 4050,
|
||||
ctr: '9.0%',
|
||||
ecpm: 9.1,
|
||||
grossRevenueUsd: 409.5,
|
||||
fillRate: '95.1%',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'net_005',
|
||||
name: 'EthicalAds Privacy Dev Network',
|
||||
adapterType: 'REST Decision API',
|
||||
format: 'Bottom Dock Banner',
|
||||
impressions: 38000,
|
||||
clicks: 608,
|
||||
ctr: '1.6%',
|
||||
ecpm: 3.8,
|
||||
grossRevenueUsd: 144.4,
|
||||
fillRate: '99.4%',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'net_006',
|
||||
name: 'Carbon Ads (BuySellAds)',
|
||||
adapterType: 'Native JSON Endpoint',
|
||||
format: 'Tech Developer Unit',
|
||||
impressions: 31000,
|
||||
clicks: 589,
|
||||
ctr: '1.9%',
|
||||
ecpm: 4.2,
|
||||
grossRevenueUsd: 130.2,
|
||||
fillRate: '98.8%',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'net_007',
|
||||
name: 'Google Ad Manager 360',
|
||||
adapterType: 'Universal Ad Server',
|
||||
format: 'Global Display & Video',
|
||||
impressions: 29000,
|
||||
clicks: 580,
|
||||
ctr: '2.0%',
|
||||
ecpm: 3.5,
|
||||
grossRevenueUsd: 101.5,
|
||||
fillRate: '99.8%',
|
||||
status: 'bidding',
|
||||
},
|
||||
{
|
||||
id: 'net_008',
|
||||
name: 'Mintegral APAC Network',
|
||||
adapterType: 'Video & Playable SDK',
|
||||
format: 'Rewarded Video (15s)',
|
||||
impressions: 22000,
|
||||
clicks: 1760,
|
||||
ctr: '8.0%',
|
||||
ecpm: 6.2,
|
||||
grossRevenueUsd: 136.4,
|
||||
fillRate: '92.4%',
|
||||
status: 'bidding',
|
||||
},
|
||||
{
|
||||
id: 'net_009',
|
||||
name: 'InMobi Programmatic Exchange',
|
||||
adapterType: 'RTB Exchange',
|
||||
format: 'Banner & Video Interstitial',
|
||||
impressions: 19000,
|
||||
clicks: 380,
|
||||
ctr: '2.0%',
|
||||
ecpm: 3.4,
|
||||
grossRevenueUsd: 64.6,
|
||||
fillRate: '91.2%',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'net_010',
|
||||
name: 'PubMatic OpenWrap SSP',
|
||||
adapterType: 'Prebid Header Bidding',
|
||||
format: 'Bottom Dock & Export Modal',
|
||||
impressions: 15000,
|
||||
clicks: 225,
|
||||
ctr: '1.5%',
|
||||
ecpm: 3.6,
|
||||
grossRevenueUsd: 54.0,
|
||||
fillRate: '90.5%',
|
||||
status: 'bidding',
|
||||
},
|
||||
]
|
||||
|
||||
const totalGrossRevenue = networks.reduce((acc, n) => acc + n.grossRevenueUsd, 0)
|
||||
const totalImpressions = networks.reduce((acc, n) => acc + n.impressions, 0)
|
||||
const avgEcpm = (totalGrossRevenue / (totalImpressions / 1000)).toFixed(2)
|
||||
|
||||
// Monthly Settlement Records (KRW Tax Withholding & Net Payout)
|
||||
const settlements: SettlementRow[] = [
|
||||
{
|
||||
id: 'STL-202607-DIRECT',
|
||||
cycleMonth: '2026-07',
|
||||
networkName: 'Direct House Sponsor (Cursor/Notion)',
|
||||
grossUsd: 1276.8,
|
||||
withholdingTax: '3.3% (₩56,870)',
|
||||
netPayoutKrw: 1666810,
|
||||
payoutStatus: 'paid',
|
||||
method: 'KB국민 928702-00-184920',
|
||||
},
|
||||
{
|
||||
id: 'STL-202607-PLAYWIRE',
|
||||
cycleMonth: '2026-07',
|
||||
networkName: 'Playwire RAMP Desktop Header Bidding',
|
||||
grossUsd: 520.8,
|
||||
withholdingTax: '3.3% (₩23,200)',
|
||||
netPayoutKrw: 679880,
|
||||
payoutStatus: 'paid',
|
||||
method: 'Wire Transfer (USD)',
|
||||
},
|
||||
{
|
||||
id: 'STL-202607-APPLOVIN',
|
||||
cycleMonth: '2026-07',
|
||||
networkName: 'AppLovin MAX In-App Bidding',
|
||||
grossUsd: 374.4,
|
||||
withholdingTax: '3.3% (₩16,680)',
|
||||
netPayoutKrw: 488760,
|
||||
payoutStatus: 'settled',
|
||||
method: 'Wire Transfer (USD)',
|
||||
},
|
||||
{
|
||||
id: 'STL-202607-UNITY',
|
||||
cycleMonth: '2026-07',
|
||||
networkName: 'Unity LevelPlay Rewarded Video',
|
||||
grossUsd: 409.5,
|
||||
withholdingTax: '3.3% (₩18,240)',
|
||||
netPayoutKrw: 534580,
|
||||
payoutStatus: 'settled',
|
||||
method: 'PayPal (yunchanpaca@gmail.com)',
|
||||
},
|
||||
{
|
||||
id: 'STL-202607-ETHICAL',
|
||||
cycleMonth: '2026-07',
|
||||
networkName: 'EthicalAds Privacy Dev Network',
|
||||
grossUsd: 144.4,
|
||||
withholdingTax: '3.3% (₩6,430)',
|
||||
netPayoutKrw: 188510,
|
||||
payoutStatus: 'settled',
|
||||
method: 'PayPal (yunchanpaca@gmail.com)',
|
||||
},
|
||||
]
|
||||
|
||||
const totalSettledKrw = settlements.reduce((acc, s) => acc + s.netPayoutKrw, 0)
|
||||
|
||||
function HeaderBar(): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
...panelSx,
|
||||
minHeight: 84,
|
||||
px: { xs: 2.5, md: 4 },
|
||||
py: 2,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
...panelSx,
|
||||
minHeight: 84,
|
||||
px: { xs: 2.5, md: 4 },
|
||||
py: 2,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 4,
|
||||
height: 32,
|
||||
borderRadius: '999px',
|
||||
background: `linear-gradient(180deg, ${C.orange} 0%, ${C.accent} 60%, ${C.purple} 100%)`
|
||||
}}
|
||||
/>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontWeight: 800,
|
||||
fontSize: { xs: '20px', md: '24px' },
|
||||
color: C.bright,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
component="h1"
|
||||
sx={{ fontFamily: FONT_SANS, fontSize: '20px', fontWeight: 500, color: C.bright, letterSpacing: '-0.02em', m: 0 }}
|
||||
>
|
||||
Multi-Ad Mediation & Revenue Settlement Hub
|
||||
Ad Mediation & Monetization
|
||||
</Typography>
|
||||
<TactileBadge mono tone="accent">10 NETWORKS ACTIVE</TactileBadge>
|
||||
<TactileBadge mono tone="success">AUCTION HEALTHY</TactileBadge>
|
||||
<TactileBadge tone="warning" mono>
|
||||
FAIL-CLOSED SANDBOX
|
||||
</TactileBadge>
|
||||
</Box>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: '12px',
|
||||
color: C.dim,
|
||||
mt: 0.5,
|
||||
}}
|
||||
>
|
||||
Real-time header bidding mediation, floor eCPM management, and automated tax withholding settlement ledger.
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, letterSpacing: '0.04em', mt: 0.25 }}>
|
||||
Mediation waterfall roster, rewarded token grants, network integration state
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: '12px',
|
||||
color: C.cyanLight,
|
||||
borderColor: 'rgba(56, 189, 248, 0.3)',
|
||||
textTransform: 'none',
|
||||
'&:hover': { borderColor: C.cyanLight, bgcolor: 'rgba(56, 189, 248, 0.08)' },
|
||||
}}
|
||||
>
|
||||
📥 Export CSV Settlement
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
sx={{
|
||||
...primaryButtonSx,
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: '12px',
|
||||
textTransform: 'none',
|
||||
}}
|
||||
>
|
||||
+ Add Demand Partner
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Publisher Account Banner */}
|
||||
<Box
|
||||
sx={{
|
||||
...panelSx,
|
||||
p: 2.5,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
bgcolor: 'rgba(14, 165, 233, 0.05)',
|
||||
border: '1px solid rgba(56, 189, 248, 0.2)',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '10px',
|
||||
bgcolor: 'rgba(56, 189, 248, 0.15)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: C.cyanLight,
|
||||
fontWeight: 800,
|
||||
fontSize: '18px',
|
||||
}}
|
||||
>
|
||||
P
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontWeight: 700, fontSize: '14px', color: C.bright }}>
|
||||
Registered Publisher Account: yunchanpaca@gmail.com
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
Payout Beneficiary: D3RO Voice AI • KB국민은행 928702-00-184920 • 사업자등록 120-88-01923
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<TactileBadge mono tone="success">KYC Verified</TactileBadge>
|
||||
<TactileBadge mono tone="accent">3.3% 원천징수 적용</TactileBadge>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<DoubleBezelCard interactive>
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
|
||||
Est. Monthly Ad Revenue
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.cyanLight, my: 0.5 }}>
|
||||
${totalGrossRevenue.toFixed(2)}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.green400 }}>
|
||||
₩{(totalGrossRevenue * 1350).toLocaleString()} (환율 ₩1,350)
|
||||
</Typography>
|
||||
</Box>
|
||||
</DoubleBezelCard>
|
||||
|
||||
<DoubleBezelCard interactive>
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
|
||||
Weighted Avg. eCPM
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.bright, my: 0.5 }}>
|
||||
${avgEcpm}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight }}>
|
||||
Floor: $2.00 min • Max: $18.00
|
||||
</Typography>
|
||||
</Box>
|
||||
</DoubleBezelCard>
|
||||
|
||||
<DoubleBezelCard interactive>
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
|
||||
Total Ad Impressions
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.bright, my: 0.5 }}>
|
||||
{(totalImpressions / 1000).toFixed(1)}k
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
Avg. Fill Rate: 96.8%
|
||||
</Typography>
|
||||
</Box>
|
||||
</DoubleBezelCard>
|
||||
|
||||
<DoubleBezelCard interactive>
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
|
||||
Net Payout Settled (KRW)
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.green400, my: 0.5 }}>
|
||||
₩{(totalSettledKrw / 10000).toFixed(1)}만
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.green400 }}>
|
||||
₩{totalSettledKrw.toLocaleString()} 입금 완료
|
||||
</Typography>
|
||||
</Box>
|
||||
</DoubleBezelCard>
|
||||
</Box>
|
||||
|
||||
{/* 10+ Multi-Ad Network Mediation Matrix */}
|
||||
<Box sx={{ ...panelSx, p: { xs: 2, md: 3 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
10+ Active Ad Networks & Header Bidding Matrix
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
First-price real-time bidding auction with sub-800ms SLA fallback to Direct House AI Sponsors.
|
||||
</Typography>
|
||||
</Box>
|
||||
<TactileBadge mono tone="default">AUCTION TIMEOUT: 800MS</TactileBadge>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Box component="table" sx={tableSx}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left' }}>Demand Partner</th>
|
||||
<th style={{ textAlign: 'left' }}>Adapter Protocol</th>
|
||||
<th style={{ textAlign: 'left' }}>Primary Slot Format</th>
|
||||
<th style={{ textAlign: 'right' }}>Impressions</th>
|
||||
<th style={{ textAlign: 'right' }}>CTR</th>
|
||||
<th style={{ textAlign: 'right' }}>Bid eCPM</th>
|
||||
<th style={{ textAlign: 'right' }}>Gross Revenue</th>
|
||||
<th style={{ textAlign: 'center' }}>Fill Rate</th>
|
||||
<th style={{ textAlign: 'center' }}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{networks.map((net) => (
|
||||
<tr key={net.id}>
|
||||
<td style={{ fontWeight: 600, color: C.bright }}>{net.name}</td>
|
||||
<td style={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight }}>{net.adapterType}</td>
|
||||
<td style={{ color: C.dim, fontSize: '12px' }}>{net.format}</td>
|
||||
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, color: C.bright }}>
|
||||
{net.impressions.toLocaleString()}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, color: C.green400 }}>{net.ctr}</td>
|
||||
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, fontWeight: 700, color: C.cyanLight }}>
|
||||
${net.ecpm.toFixed(2)}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, fontWeight: 700, color: C.bright }}>
|
||||
${net.grossRevenueUsd.toFixed(2)}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', fontFamily: FONT_MONO, color: C.dim }}>{net.fillRate}</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
...statusBadgeSx,
|
||||
bgcolor:
|
||||
net.status === 'active'
|
||||
? 'rgba(34, 197, 94, 0.12)'
|
||||
: 'rgba(56, 189, 248, 0.12)',
|
||||
color: net.status === 'active' ? C.green400 : C.cyanLight,
|
||||
borderColor:
|
||||
net.status === 'active' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(56, 189, 248, 0.3)',
|
||||
}}
|
||||
>
|
||||
{net.status.toUpperCase()}
|
||||
</Box>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Monthly Settlement & Tax Withholding Payout Ledger */}
|
||||
<Box sx={{ ...panelSx, p: { xs: 2, md: 3 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
Monthly Revenue Settlement & Payout Ledger
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
Net-30 / Net-60 cycle settlements with automatic 3.3% Korean withholding tax deduction.
|
||||
</Typography>
|
||||
</Box>
|
||||
<TactileBadge mono tone="success">TAX WITHHOLDING AUTO-CALCULATED</TactileBadge>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Box component="table" sx={tableSx}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left' }}>Settlement ID</th>
|
||||
<th style={{ textAlign: 'left' }}>Cycle Month</th>
|
||||
<th style={{ textAlign: 'left' }}>Network Source</th>
|
||||
<th style={{ textAlign: 'right' }}>Gross ($ USD)</th>
|
||||
<th style={{ textAlign: 'center' }}>Withholding Tax</th>
|
||||
<th style={{ textAlign: 'right' }}>Net Payout (₩ KRW)</th>
|
||||
<th style={{ textAlign: 'left' }}>Beneficiary Method</th>
|
||||
<th style={{ textAlign: 'center' }}>Payout Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{settlements.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td style={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight }}>{s.id}</td>
|
||||
<td style={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>{s.cycleMonth}</td>
|
||||
<td style={{ fontWeight: 600, color: C.bright }}>{s.networkName}</td>
|
||||
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, color: C.bright }}>
|
||||
${s.grossUsd.toFixed(2)}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', fontFamily: FONT_MONO, fontSize: '11px', color: C.orange400 }}>
|
||||
{s.withholdingTax}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, fontWeight: 700, color: C.green400 }}>
|
||||
₩{s.netPayoutKrw.toLocaleString()}
|
||||
</td>
|
||||
<td style={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>{s.method}</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
...statusBadgeSx,
|
||||
bgcolor:
|
||||
s.payoutStatus === 'paid'
|
||||
? 'rgba(34, 197, 94, 0.12)'
|
||||
: 'rgba(234, 179, 8, 0.12)',
|
||||
color: s.payoutStatus === 'paid' ? C.green400 : C.orange400,
|
||||
borderColor:
|
||||
s.payoutStatus === 'paid' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(234, 179, 8, 0.3)',
|
||||
}}
|
||||
>
|
||||
{s.payoutStatus.toUpperCase()}
|
||||
</Box>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function RewardKpis({ stats }: { stats: AdRewardStats }): React.ReactElement {
|
||||
const cards = [
|
||||
{
|
||||
title: `Rewarded Claims (${stats.windowDays}d)`,
|
||||
value: stats.totalClaims.toLocaleString(),
|
||||
subtext: 'Verified rewarded-video completions'
|
||||
},
|
||||
{
|
||||
title: `Tokens Granted (${stats.windowDays}d)`,
|
||||
value: stats.totalRewardTokens.toLocaleString(),
|
||||
subtext: 'Cloud AI tokens issued via ad rewards'
|
||||
},
|
||||
{
|
||||
title: `Unique Claimants (${stats.windowDays}d)`,
|
||||
value: stats.uniqueClaimants.toLocaleString(),
|
||||
subtext: 'Distinct accounts that redeemed rewards'
|
||||
},
|
||||
{
|
||||
title: 'Live Ad Networks',
|
||||
value: `0 / ${MEDIATION_ROSTER.length}`,
|
||||
subtext: 'All adapters fail-closed until SDK contracts land'
|
||||
}
|
||||
]
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' }, gap: 2 }}>
|
||||
{cards.map((card) => (
|
||||
<Box key={card.title} sx={{ ...panelSx, p: 2.5 }}>
|
||||
<Typography
|
||||
sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mb: 1, textTransform: 'uppercase', letterSpacing: '0.08em' }}
|
||||
>
|
||||
{card.title}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '22px', fontWeight: 700, color: C.bright, lineHeight: 1.25 }}>
|
||||
{card.value}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>{card.subtext}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function MediationRosterPanel(): React.ReactElement {
|
||||
return (
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1.5, mb: 1 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright }}>
|
||||
Mediation Waterfall Roster
|
||||
</Typography>
|
||||
<Box sx={statusBadgeSx('orange')}>NO LIVE BIDS</Box>
|
||||
</Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 2 }}>
|
||||
데스크톱 미디에이션 엔진에 등록된 어댑터 구성입니다. 공식 SDK/인증 계약이 연결되기 전까지 모든 네트워크는
|
||||
입찰 없이 fail-closed로 동작하며, 데모 크리에이티브와 가짜 수익 수치는 표시하지 않습니다.
|
||||
</Typography>
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Box component="table" sx={tableSx}>
|
||||
<Box component="thead">
|
||||
<Box component="tr">
|
||||
<Box component="th">Network</Box>
|
||||
<Box component="th">Adapter ID</Box>
|
||||
<Box component="th">Formats</Box>
|
||||
<Box component="th">Floor eCPM</Box>
|
||||
<Box component="th">Integration</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{MEDIATION_ROSTER.map((network) => (
|
||||
<Box component="tr" key={network.networkId}>
|
||||
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.bright }}>
|
||||
{network.name}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
|
||||
{network.networkId}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '12px' }}>
|
||||
{network.formats.map((format) => FORMAT_LABEL[format] ?? format).join(' · ')}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
|
||||
${network.floorEcpm.toFixed(2)}
|
||||
</Box>
|
||||
<Box component="td">
|
||||
<Box sx={statusBadgeSx('orange')}>NOT INTEGRATED</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</DoubleBezelCard>
|
||||
)
|
||||
}
|
||||
|
||||
function RewardBreakdownPanel({ stats }: { stats: AdRewardStats }): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1fr 1.4fr' }, gap: 3 }}>
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright, mb: 2 }}>
|
||||
Rewarded Grants by Network
|
||||
</Typography>
|
||||
{stats.byNetwork.length === 0 ? (
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>
|
||||
최근 {stats.windowDays}일간 검증된 리워드 클레임이 없습니다.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Box component="table" sx={tableSx}>
|
||||
<Box component="thead">
|
||||
<Box component="tr">
|
||||
<Box component="th">Network</Box>
|
||||
<Box component="th">Claims</Box>
|
||||
<Box component="th">Tokens</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{stats.byNetwork.map((summary) => (
|
||||
<Box component="tr" key={summary.network}>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
|
||||
{summary.network}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
|
||||
{summary.claims.toLocaleString()}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.green400 }}>
|
||||
+{summary.rewardTokens.toLocaleString()}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DoubleBezelCard>
|
||||
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright, mb: 2 }}>
|
||||
Recent Verified Claims
|
||||
</Typography>
|
||||
{stats.recentClaims.length === 0 ? (
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>표시할 클레임이 없습니다.</Typography>
|
||||
) : (
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Box component="table" sx={tableSx}>
|
||||
<Box component="thead">
|
||||
<Box component="tr">
|
||||
<Box component="th">Verified At (UTC)</Box>
|
||||
<Box component="th">Network</Box>
|
||||
<Box component="th">Placement</Box>
|
||||
<Box component="th">Tokens</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{stats.recentClaims.map((claim) => (
|
||||
<Box component="tr" key={claim.id}>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
|
||||
{formatDateTime(claim.verifiedAt)}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
|
||||
{claim.network}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '12px' }}>{claim.placement}</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.green400 }}>
|
||||
+{claim.rewardTokens.toLocaleString()}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DoubleBezelCard>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function RewardsUnavailablePanel({ reason }: { reason: string }): React.ReactElement {
|
||||
return (
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '14px', fontWeight: 600, color: C.orange400, mb: 1 }}>
|
||||
Rewarded 토큰 지급 통계를 불러올 수 없습니다.
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim }}>{reason}</Typography>
|
||||
</DoubleBezelCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function AdminAdsPage(): Promise<React.ReactElement> {
|
||||
await requireManager()
|
||||
|
||||
let stats: AdRewardStats | null = null
|
||||
let statsError: string | null = null
|
||||
if (!isSupabaseAdminConfigured()) {
|
||||
statsError =
|
||||
'SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 ad_reward_claims 데이터에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다.'
|
||||
} else {
|
||||
try {
|
||||
stats = await fetchAdRewardStats()
|
||||
} catch (error) {
|
||||
statsError = error instanceof Error ? error.message : 'Unknown ad reward stats error'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<HeaderBar />
|
||||
{stats && <RewardKpis stats={stats} />}
|
||||
<MediationRosterPanel />
|
||||
{stats ? <RewardBreakdownPanel stats={stats} /> : <RewardsUnavailablePanel reason={statsError ?? ''} />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@
|
|||
import { Box, Typography } from '@mui/material'
|
||||
import { C, FONT_SANS, FONT_MONO, panelSx, statusBadgeSx } from '@/lib/console-theme'
|
||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import { getSupabaseAdminClient, isSupabaseAdminConfigured } from '@/lib/supabase-admin'
|
||||
import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel'
|
||||
import { requireManager } from '@/lib/admin-guard'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { AuditDiffViewer } from '@/components/audit-diff-viewer'
|
||||
import { sanitizeAuditSnapshot } from '@/lib/audit-sanitize'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
|
|
@ -15,40 +18,36 @@ interface PageProps {
|
|||
|
||||
export default async function AuditLogDetailPage({ params }: PageProps): Promise<React.ReactElement> {
|
||||
await requireManager()
|
||||
const { id } = await params
|
||||
const supabase = await getSupabaseServerClient()
|
||||
|
||||
const { data: log } = await supabase
|
||||
.from('audit_log')
|
||||
.select('*')
|
||||
.eq('id', parseInt(id, 10))
|
||||
.maybeSingle()
|
||||
|
||||
const typedLog = log || {
|
||||
id: parseInt(id, 10) || 101,
|
||||
admin_id: 'usr_d3ro_001',
|
||||
action: 'MODEL_ENDPOINT_UPDATE',
|
||||
target_type: 'model',
|
||||
target_id: 'whisper-large-v3-turbo',
|
||||
created_at: '2026-08-19T10:45:00Z',
|
||||
memo: 'Enabled 6.2x turbo acceleration, updated model path to weights/large-v3-turbo.pt and tuned dual-condition parallel buffer flush threshold.',
|
||||
before_data: {
|
||||
model_id: 'whisper-large-v3',
|
||||
acceleration: '1.0x',
|
||||
latency_ms: 880,
|
||||
buffer_flush: 'sequential',
|
||||
is_default: true,
|
||||
},
|
||||
after_data: {
|
||||
model_id: 'whisper-large-v3-turbo',
|
||||
acceleration: '6.2x',
|
||||
latency_ms: 142,
|
||||
buffer_flush: 'parallel-dual-condition',
|
||||
is_default: true,
|
||||
},
|
||||
if (!isSupabaseAdminConfigured()) {
|
||||
return (
|
||||
<UnavailableAdminPanel
|
||||
title="Audit Log Detail"
|
||||
capability="Admin actions, changes, accountability"
|
||||
reason="SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 Supabase 관리 데이터에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다."
|
||||
/>
|
||||
)
|
||||
}
|
||||
const { id } = await params
|
||||
const logId = Number.parseInt(id, 10)
|
||||
if (!Number.isSafeInteger(logId) || logId <= 0) notFound()
|
||||
const supabase = await getSupabaseAdminClient()
|
||||
|
||||
const adminName = 'D3RO System Administrator'
|
||||
const { data: log, error: logError } = await supabase
|
||||
.from('audit_log')
|
||||
.select('id, admin_id, action, target_type, target_id, before_data, after_data, memo, created_at')
|
||||
.eq('id', logId)
|
||||
.maybeSingle()
|
||||
if (logError) throw new Error(`Supabase audit detail failed: ${logError.message}`)
|
||||
if (!log) notFound()
|
||||
const typedLog = log
|
||||
|
||||
const { data: adminProfile, error: adminError } = await supabase
|
||||
.from('profiles')
|
||||
.select('name')
|
||||
.eq('id', typedLog.admin_id)
|
||||
.maybeSingle()
|
||||
if (adminError) throw new Error(`Supabase audit actor failed: ${adminError.message}`)
|
||||
const adminName = typeof adminProfile?.name === 'string' && adminProfile.name ? adminProfile.name : 'Unknown administrator'
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -82,7 +81,7 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise
|
|||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
color: C.bright,
|
||||
letterSpacing: '-0.02em',
|
||||
m: 0,
|
||||
|
|
@ -99,7 +98,7 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise
|
|||
← Back to Audit Ledger
|
||||
</Link>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
SHA-256 Checksum Verified
|
||||
Persisted audit record
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -111,7 +110,7 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise
|
|||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1fr 1fr' }, gap: 3 }}>
|
||||
{/* Details Card */}
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright, mb: 2 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright, mb: 2 }}>
|
||||
Transaction Metadata
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, fontFamily: FONT_SANS, fontSize: '13px' }}>
|
||||
|
|
@ -125,7 +124,7 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise
|
|||
|
||||
{/* Memo Card */}
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright, mb: 2 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright, mb: 2 }}>
|
||||
Administrative Intent & Reason
|
||||
</Typography>
|
||||
<Box
|
||||
|
|
@ -148,7 +147,7 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise
|
|||
{/* Visual JSON State Diff */}
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
|
||||
Entity State Transition Diff (Before vs After)
|
||||
</Typography>
|
||||
<TactileBadge tone="success" mono>
|
||||
|
|
@ -157,8 +156,8 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise
|
|||
</Box>
|
||||
|
||||
<AuditDiffViewer
|
||||
beforeData={typedLog.before_data as Record<string, unknown> | null}
|
||||
afterData={typedLog.after_data as Record<string, unknown> | null}
|
||||
beforeData={sanitizeAuditSnapshot(typedLog.before_data)}
|
||||
afterData={sanitizeAuditSnapshot(typedLog.after_data)}
|
||||
/>
|
||||
</DoubleBezelCard>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
import { Box, Typography, Button } from '@mui/material'
|
||||
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, filterBtnSx, statusBadgeSx } from '@/lib/console-theme'
|
||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import { getSupabaseAdminClient, isSupabaseAdminConfigured } from '@/lib/supabase-admin'
|
||||
import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel'
|
||||
import { requireManager } from '@/lib/admin-guard'
|
||||
import Link from 'next/link'
|
||||
|
||||
|
|
@ -14,36 +15,40 @@ interface PageProps {
|
|||
|
||||
export default async function AuditLogPage({ searchParams }: PageProps): Promise<React.ReactElement> {
|
||||
await requireManager()
|
||||
if (!isSupabaseAdminConfigured()) {
|
||||
return (
|
||||
<UnavailableAdminPanel
|
||||
title="Audit Log"
|
||||
capability="Admin actions, changes, accountability"
|
||||
reason="SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 Supabase 관리 데이터에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다."
|
||||
/>
|
||||
)
|
||||
}
|
||||
const params = await searchParams
|
||||
const targetTypeFilter = params.target_type ?? 'all'
|
||||
const page = parseInt(params.page ?? '1', 10)
|
||||
if (!['all', 'subscription', 'profile'].includes(targetTypeFilter)) {
|
||||
throw new Error('Invalid audit target type filter')
|
||||
}
|
||||
const pageValue = params.page ?? '1'
|
||||
if (!/^[1-9]\d{0,6}$/.test(pageValue)) throw new Error('Invalid audit page')
|
||||
const page = Number(pageValue)
|
||||
const limit = 20
|
||||
const from = (page - 1) * limit
|
||||
const to = from + limit - 1
|
||||
|
||||
const supabase = await getSupabaseServerClient()
|
||||
const supabase = await getSupabaseAdminClient()
|
||||
|
||||
let query = supabase.from('audit_log').select('*', { count: 'exact' })
|
||||
let query = supabase.from('audit_log').select('id, admin_id, action, target_type, target_id, memo, created_at', { count: 'exact' })
|
||||
if (targetTypeFilter !== 'all') {
|
||||
query = query.eq('target_type', targetTypeFilter)
|
||||
}
|
||||
|
||||
const { data: rawLogs } = await query
|
||||
const { data: rawLogs, error: logsError } = await query
|
||||
.order('created_at', { ascending: false })
|
||||
.range(from, to)
|
||||
if (logsError) throw new Error(`Supabase audit log failed: ${logsError.message}`)
|
||||
|
||||
let logs = (rawLogs ?? []) as Array<Record<string, unknown>>
|
||||
|
||||
if (logs.length === 0) {
|
||||
// Rich Mock Security Audit Logs
|
||||
logs = [
|
||||
{ id: 101, created_at: '2026-08-19T10:45:00Z', admin_id: 'usr_d3ro_001', admin_name: 'Admin User', action: 'MODEL_ENDPOINT_UPDATE', target_type: 'model', target_id: 'whisper-large-v3-turbo', memo: 'Enabled 6.2x turbo acceleration & parallel flush' },
|
||||
{ id: 102, created_at: '2026-08-19T09:12:00Z', admin_id: 'usr_d3ro_001', admin_name: 'Admin User', action: 'SUBSCRIPTION_UPGRADE', target_type: 'subscription', target_id: 'usr_d3ro_002', memo: 'Upgraded Sarah Kim to PRO+ VIP tier' },
|
||||
{ id: 103, created_at: '2026-08-18T16:30:00Z', admin_id: 'usr_d3ro_001', admin_name: 'Admin User', action: 'SECURITY_POLICY_CHECK', target_type: 'system', target_id: 'cors_whitelist', memo: 'Verified CORS allowlist for desktop/web clients' },
|
||||
{ id: 104, created_at: '2026-08-18T14:15:00Z', admin_id: 'usr_d3ro_001', admin_name: 'Admin User', action: 'VECTOR_INDEX_REBUILD', target_type: 'vector_rag', target_id: 'sqlite_vec_01', memo: 'Reindexed 4,820 documents with nomic-embed-text' },
|
||||
{ id: 105, created_at: '2026-08-17T11:00:00Z', admin_id: 'usr_d3ro_001', admin_name: 'Admin User', action: 'DIARIZATION_THRESHOLD_SET', target_type: 'pipeline', target_id: 'pyannote_3.1', memo: 'Adjusted speaker similarity clustering threshold to 0.72' },
|
||||
]
|
||||
}
|
||||
const logs = (rawLogs ?? []) as Array<Record<string, unknown>>
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -77,7 +82,7 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
|
|||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
color: C.bright,
|
||||
letterSpacing: '-0.02em',
|
||||
m: 0,
|
||||
|
|
@ -86,7 +91,7 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
|
|||
Security Audit Log & Event Ledger
|
||||
</Typography>
|
||||
<TactileBadge tone="mono" mono>
|
||||
IMMUTABLE AUDIT TRAIL
|
||||
PERSISTED AUDIT TRAIL
|
||||
</TactileBadge>
|
||||
</Box>
|
||||
<Typography
|
||||
|
|
@ -98,13 +103,13 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
|
|||
mt: 0.25,
|
||||
}}
|
||||
>
|
||||
ADMIN ACTION AUDIT • TIER MODIFICATIONS • ENDPOINT CONFIGURATION TRACE
|
||||
Admin action audit, role and subscription modifications
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{['all', 'subscription', 'profile', 'model', 'system'].map((t) => (
|
||||
{['all', 'subscription', 'profile'].map((t) => (
|
||||
<Link key={t} href={`/audit-log?target_type=${t}`} style={{ textDecoration: 'none' }}>
|
||||
<Box sx={filterBtnSx(targetTypeFilter === t)}>
|
||||
{t.toUpperCase()}
|
||||
|
|
@ -117,11 +122,11 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
|
|||
{/* Main Table Card */}
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
|
||||
Chronological Security Log Entries
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
AUTO-SIGN SHA-256 VERIFIED
|
||||
DATABASE AUDIT RECORDS
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
|
@ -138,7 +143,13 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
|
|||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{logs.map((log) => (
|
||||
{logs.length === 0 ? (
|
||||
<Box component="tr">
|
||||
<Box component="td" colSpan={6} sx={{ textAlign: 'center', color: C.dim, py: 4 }}>
|
||||
No audit events match the selected filters.
|
||||
</Box>
|
||||
</Box>
|
||||
) : logs.map((log) => (
|
||||
<Box component="tr" key={log.id as number}>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
|
||||
{new Date(log.created_at as string).toLocaleString()}
|
||||
|
|
|
|||
22
apps/admin/src/app/(admin)/error.tsx
Normal file
22
apps/admin/src/app/(admin)/error.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
'use client'
|
||||
|
||||
import { Alert, Box, Button, Typography } from '@mui/material'
|
||||
|
||||
export default function AdminError({ reset }: { error: Error & { digest?: string }; reset: () => void }): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ p: 4 }}>
|
||||
<Alert severity="error" sx={{ alignItems: 'center' }}>
|
||||
<Typography component="h1" sx={{ fontWeight: 500, mb: 0.5 }}>
|
||||
관리자 데이터를 불러오지 못했습니다.
|
||||
</Typography>
|
||||
<Typography sx={{ mb: 1.5 }}>
|
||||
인증, 권한 또는 백엔드 연결 오류가 발생했습니다. 샘플 데이터로 대체하지 않습니다.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button variant="outlined" color="error" onClick={reset}>다시 시도</Button>
|
||||
<Button variant="outlined" color="error" href="/api/auth/logout">다시 로그인</Button>
|
||||
</Box>
|
||||
</Alert>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ export default async function AdminLayout({
|
|||
}: {
|
||||
children: React.ReactNode
|
||||
}): Promise<React.ReactElement> {
|
||||
await requireManager()
|
||||
const admin = await requireManager()
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
|
@ -27,7 +27,7 @@ export default async function AdminLayout({
|
|||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<AdminSidebar />
|
||||
<AdminSidebar identity={{ email: admin.email ?? admin.id, role: admin.role }} />
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
|
|
@ -56,4 +56,3 @@ export default async function AdminLayout({
|
|||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,18 +24,22 @@ import {
|
|||
} from '@mui/material'
|
||||
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx, primaryButtonSx } from '@/lib/console-theme'
|
||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||
import {
|
||||
fetchModelEndpoints,
|
||||
fetchSttEndpoints,
|
||||
createSttEndpoint,
|
||||
updateSttEndpoint,
|
||||
deleteSttEndpoint,
|
||||
setDefaultSttEndpoint,
|
||||
testSttEndpoint,
|
||||
type ModelEndpoint,
|
||||
type SttProviderEndpoint,
|
||||
type STTProviderCategory,
|
||||
import type {
|
||||
ModelEndpoint,
|
||||
SttProviderEndpoint,
|
||||
STTProviderCategory,
|
||||
} from '@/lib/api-server'
|
||||
import {
|
||||
createModelEndpointClient,
|
||||
createSttEndpointClient,
|
||||
deleteModelEndpointClient,
|
||||
deleteSttEndpointClient,
|
||||
fetchModelEndpointsClient,
|
||||
fetchSttEndpointsClient,
|
||||
setDefaultSttEndpointClient,
|
||||
testSttEndpointClient,
|
||||
updateSttEndpointClient,
|
||||
} from '@/lib/backend-admin-client'
|
||||
|
||||
const PRESET_LLM_MODELS = [
|
||||
{ modelId: 'whisper-large-v3-turbo', modelName: 'Faster-Whisper Large-v3 Turbo (Local)', provider: 'Local Sidecar', endpointUrl: 'http://localhost:8971/stt/transcribe', promptCost: '0.000000', completionCost: '0.000000' },
|
||||
|
|
@ -50,82 +54,19 @@ const PRESET_STT_PROVIDERS: Array<{
|
|||
providerType: STTProviderCategory
|
||||
endpointUrl: string
|
||||
modelId: string
|
||||
method: string
|
||||
method: 'multipart' | 'binary-stream' | 'json-base64' | 'custom-rest'
|
||||
costPerMinute: number
|
||||
language: string
|
||||
prompt?: string
|
||||
description: string
|
||||
}> = [
|
||||
{
|
||||
name: 'Groq Whisper LPU Turbo (Ultra Fast)',
|
||||
providerType: 'groq',
|
||||
endpointUrl: 'https://api.groq.com/openai/v1/audio/transcriptions',
|
||||
modelId: 'whisper-large-v3-turbo',
|
||||
method: 'multipart',
|
||||
costPerMinute: 0.0005,
|
||||
language: 'ko',
|
||||
description: 'LPU 가속 기반 초저지연(~140ms) 고속 전사, 극저비용',
|
||||
},
|
||||
{
|
||||
name: 'OpenAI Whisper Official',
|
||||
providerType: 'openai',
|
||||
endpointUrl: 'https://api.openai.com/v1/audio/transcriptions',
|
||||
modelId: 'whisper-1',
|
||||
method: 'multipart',
|
||||
costPerMinute: 0.006,
|
||||
language: 'ko',
|
||||
description: 'OpenAI 공식 Whisper-1 모델, 표준 고품질 다국어 인식',
|
||||
},
|
||||
{
|
||||
name: 'Deepgram Nova-3 Industry Standard',
|
||||
providerType: 'deepgram',
|
||||
endpointUrl: 'https://api.deepgram.com/v1/listen',
|
||||
modelId: 'nova-3',
|
||||
method: 'binary-stream',
|
||||
costPerMinute: 0.0043,
|
||||
language: 'ko',
|
||||
description: 'Nova-3 스마트 구두점, 실시간 스트리밍 최적화 및 고정밀 전사',
|
||||
},
|
||||
{
|
||||
name: 'Google Gemini 2.0 Flash / Cloud STT',
|
||||
providerType: 'google',
|
||||
endpointUrl: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent',
|
||||
modelId: 'gemini-2.0-flash',
|
||||
method: 'json-base64',
|
||||
costPerMinute: 0.001,
|
||||
language: 'ko',
|
||||
description: 'Gemini 2.0 Flash 기반 다국어 및 문맥 인식 오디오 전사',
|
||||
},
|
||||
{
|
||||
name: 'AssemblyAI Universal-2',
|
||||
providerType: 'assemblyai',
|
||||
endpointUrl: 'https://api.assemblyai.com/v2/transcript',
|
||||
modelId: 'best',
|
||||
method: 'multipart',
|
||||
costPerMinute: 0.0025,
|
||||
language: 'ko',
|
||||
description: '문맥 인식 음향 모델 및 자동 단락 구분 STT',
|
||||
},
|
||||
{
|
||||
name: 'Microsoft Azure Speech Service',
|
||||
providerType: 'azure',
|
||||
endpointUrl: 'https://koreacentral.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1',
|
||||
modelId: 'azure-speech',
|
||||
method: 'binary-stream',
|
||||
costPerMinute: 0.005,
|
||||
language: 'ko-KR',
|
||||
description: 'Azure Cognitive Speech API 엔터프라이즈 음성 인식',
|
||||
},
|
||||
{
|
||||
name: 'Self-Hosted / Local Faster-Whisper Sidecar',
|
||||
providerType: 'local-sidecar',
|
||||
endpointUrl: 'http://localhost:8971/stt/transcribe',
|
||||
modelId: 'whisper-large-v3-turbo',
|
||||
method: 'multipart',
|
||||
costPerMinute: 0.0,
|
||||
language: 'ko',
|
||||
description: '사내 프라이빗 서버 또는 로컬 Whisper 사이드카 (완전 무료/오프라인)',
|
||||
},
|
||||
{ name: 'Groq Whisper LPU Turbo (Ultra Fast)', providerType: 'groq', endpointUrl: 'https://api.groq.com/openai/v1/audio/transcriptions', modelId: 'whisper-large-v3-turbo', method: 'multipart', costPerMinute: 0.0005, language: 'ko', description: 'LPU 가속 기반 초저지연(~140ms) 고속 전사, 극저비용' },
|
||||
{ name: 'OpenAI Whisper Official', providerType: 'openai', endpointUrl: 'https://api.openai.com/v1/audio/transcriptions', modelId: 'whisper-1', method: 'multipart', costPerMinute: 0.006, language: 'ko', description: 'OpenAI 공식 Whisper-1 모델, 표준 고품질 다국어 인식' },
|
||||
{ name: 'Deepgram Nova-3 Industry Standard', providerType: 'deepgram', endpointUrl: 'https://api.deepgram.com/v1/listen', modelId: 'nova-3', method: 'binary-stream', costPerMinute: 0.0043, language: 'ko', description: 'Nova-3 스마트 구두점, 실시간 스트리밍 최적화 및 고정밀 전사' },
|
||||
{ name: 'Google Gemini 2.0 Flash / Cloud STT', providerType: 'google', endpointUrl: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent', modelId: 'gemini-2.0-flash', method: 'json-base64', costPerMinute: 0.001, language: 'ko', description: 'Gemini 2.0 Flash 기반 다국어 및 문맥 인식 오디오 전사' },
|
||||
{ name: 'AssemblyAI Universal-2', providerType: 'assemblyai', endpointUrl: 'https://api.assemblyai.com/v2/transcript', modelId: 'best', method: 'multipart', costPerMinute: 0.0025, language: 'ko', description: '문맥 인식 음향 모델 및 자동 단락 구분 STT' },
|
||||
{ name: 'Microsoft Azure Speech Service', providerType: 'azure', endpointUrl: 'https://koreacentral.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1', modelId: 'azure-speech', method: 'binary-stream', costPerMinute: 0.005, language: 'ko-KR', description: 'Azure Cognitive Speech API 엔터프라이즈 음성 인식' },
|
||||
{ name: 'Self-Hosted / Local Faster-Whisper Sidecar', providerType: 'local-sidecar', endpointUrl: 'http://localhost:8971/stt/transcribe', modelId: 'whisper-large-v3-turbo', method: 'multipart', costPerMinute: 0.0, language: 'ko', description: '사내 프라이빗 서버 또는 로컬 Whisper 사이드카 (완전 무료/오프라인)' },
|
||||
]
|
||||
|
||||
export default function ServiceModelsPage(): React.ReactElement {
|
||||
|
|
@ -142,39 +83,42 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
|
||||
// STT Form State
|
||||
const [sttName, setSttName] = useState('')
|
||||
const [sttProviderType, setSttProviderType] = useState<STTProviderCategory>('groq')
|
||||
const [sttProviderType, setSttProviderType] = useState<STTProviderCategory>('custom')
|
||||
const [sttEndpointUrl, setSttEndpointUrl] = useState('')
|
||||
const [sttApiKey, setSttApiKey] = useState('')
|
||||
const [sttModelId, setSttModelId] = useState('whisper-large-v3-turbo')
|
||||
const [sttModelId, setSttModelId] = useState('')
|
||||
const [sttMethod, setSttMethod] = useState<'multipart' | 'binary-stream' | 'json-base64' | 'custom-rest'>('multipart')
|
||||
const [sttLanguage, setSttLanguage] = useState('ko')
|
||||
const [sttPrompt, setSttPrompt] = useState('')
|
||||
const [sttCostPerMinute, setSttCostPerMinute] = useState('0.000500')
|
||||
const [sttCostPerMinute, setSttCostPerMinute] = useState('0')
|
||||
const [sttFallbackPriority, setSttFallbackPriority] = useState(1)
|
||||
const [sttIsDefault, setSttIsDefault] = useState(false)
|
||||
const [sttMemo, setSttMemo] = useState('')
|
||||
|
||||
// LLM Endpoints State
|
||||
const [llmEndpoints, setLlmEndpoints] = useState<ModelEndpoint[]>([])
|
||||
const [llmModalOpen, setLlmModalOpen] = useState(false)
|
||||
const [llmLoading, setLlmLoading] = useState(false)
|
||||
const [llmPingStatus, setLlmPingStatus] = useState<Record<string, string>>({})
|
||||
const [dataError, setDataError] = useState<string | null>(null)
|
||||
|
||||
// LLM Form State
|
||||
const [llmModelId, setLlmModelId] = useState('')
|
||||
const [llmModelName, setLlmModelName] = useState('')
|
||||
const [llmProvider, setLlmProvider] = useState<string>('OpenAI')
|
||||
const [llmProvider, setLlmProvider] = useState<string>('Custom')
|
||||
const [llmEndpointUrl, setLlmEndpointUrl] = useState('')
|
||||
const [llmApiKey, setLlmApiKey] = useState('')
|
||||
const [llmPromptCost, setLlmPromptCost] = useState('0.000150')
|
||||
const [llmCompletionCost, setLlmCompletionCost] = useState('0.000600')
|
||||
const [llmPromptCost, setLlmPromptCost] = useState('0')
|
||||
const [llmCompletionCost, setLlmCompletionCost] = useState('0')
|
||||
const [llmMemo, setLlmMemo] = useState('')
|
||||
|
||||
const loadData = async () => {
|
||||
setDataError(null)
|
||||
try {
|
||||
const [sttData, llmData] = await Promise.all([fetchSttEndpoints(), fetchModelEndpoints()])
|
||||
const [sttData, llmData] = await Promise.all([fetchSttEndpointsClient(), fetchModelEndpointsClient()])
|
||||
setSttEndpoints(sttData)
|
||||
setLlmEndpoints(llmData)
|
||||
} catch {
|
||||
// Fallbacks handled in fetch functions
|
||||
} catch (error) {
|
||||
setDataError(error instanceof Error ? error.message : '관리자 백엔드에서 엔드포인트를 불러오지 못했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,34 +128,21 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
|
||||
// ── STT Handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
const applySttPreset = (presetName: string) => {
|
||||
const p = PRESET_STT_PROVIDERS.find((x) => x.name === presetName)
|
||||
if (!p) return
|
||||
setSttName(p.name)
|
||||
setSttProviderType(p.providerType)
|
||||
setSttEndpointUrl(p.endpointUrl)
|
||||
setSttModelId(p.modelId)
|
||||
setSttMethod(p.method as SttProviderEndpoint['method'])
|
||||
setSttCostPerMinute(p.costPerMinute.toFixed(6))
|
||||
setSttLanguage(p.language)
|
||||
if (p.prompt) setSttPrompt(p.prompt)
|
||||
setSttTestModalResult(null)
|
||||
}
|
||||
|
||||
const openAddSttModal = () => {
|
||||
setSttEditingId(null)
|
||||
setSttName('')
|
||||
setSttProviderType('groq')
|
||||
setSttEndpointUrl('https://api.groq.com/openai/v1/audio/transcriptions')
|
||||
setSttProviderType('custom')
|
||||
setSttEndpointUrl('')
|
||||
setSttApiKey('')
|
||||
setSttModelId('whisper-large-v3-turbo')
|
||||
setSttModelId('')
|
||||
setSttMethod('multipart')
|
||||
setSttLanguage('ko')
|
||||
setSttPrompt('')
|
||||
setSttCostPerMinute('0.000500')
|
||||
setSttCostPerMinute('0')
|
||||
setSttFallbackPriority(sttEndpoints.length + 1)
|
||||
setSttIsDefault(sttEndpoints.length === 0)
|
||||
setSttTestModalResult(null)
|
||||
setSttMemo('')
|
||||
setSttModalOpen(true)
|
||||
}
|
||||
|
||||
|
|
@ -252,12 +183,13 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
isDefault: sttIsDefault,
|
||||
isActive: true,
|
||||
fallbackPriority: sttFallbackPriority,
|
||||
memo: sttMemo.trim(),
|
||||
}
|
||||
|
||||
if (sttEditingId) {
|
||||
await updateSttEndpoint(sttEditingId, payload)
|
||||
await updateSttEndpointClient(sttEditingId, payload)
|
||||
} else {
|
||||
await createSttEndpoint(payload)
|
||||
await createSttEndpointClient(payload)
|
||||
}
|
||||
|
||||
await loadData()
|
||||
|
|
@ -270,8 +202,10 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
}
|
||||
|
||||
const handleSetDefaultStt = async (id: number) => {
|
||||
const memo = prompt('기본 STT 공급자 변경 사유를 입력하세요.')?.trim() ?? ''
|
||||
if (memo.length < 3) return
|
||||
try {
|
||||
await setDefaultSttEndpoint(id)
|
||||
await setDefaultSttEndpointClient(id, memo)
|
||||
setSttEndpoints((prev) =>
|
||||
prev.map((ep) => ({
|
||||
...ep,
|
||||
|
|
@ -285,11 +219,17 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
|
||||
const handlePingStt = async (id: number) => {
|
||||
setSttPingStatus((prev) => ({ ...prev, [id]: 'Testing...' }))
|
||||
const result = await testSttEndpoint(id)
|
||||
if (result.success) {
|
||||
setSttPingStatus((prev) => ({ ...prev, [id]: `⚡ OK • ${result.latencyMs}ms` }))
|
||||
} else {
|
||||
setSttPingStatus((prev) => ({ ...prev, [id]: `❌ Failed` }))
|
||||
try {
|
||||
const result = await testSttEndpointClient(id)
|
||||
setSttPingStatus((prev) => ({
|
||||
...prev,
|
||||
[id]: result.success ? `⚡ OK • ${result.latencyMs}ms` : `❌ ${result.message}`
|
||||
}))
|
||||
} catch (error) {
|
||||
setSttPingStatus((prev) => ({
|
||||
...prev,
|
||||
[id]: `❌ ${error instanceof Error ? error.message : 'Test failed'}`
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -297,7 +237,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
setTestingInModal(true)
|
||||
setSttTestModalResult(null)
|
||||
try {
|
||||
const result = await testSttEndpoint(sttEditingId ?? 0, sttApiKey, sttEndpointUrl)
|
||||
const result = await testSttEndpointClient(sttEditingId ?? 0, sttApiKey, sttEndpointUrl)
|
||||
setSttTestModalResult({
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
|
|
@ -316,8 +256,10 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
|
||||
const handleDeleteStt = async (id: number) => {
|
||||
if (!confirm('이 STT 프로바이더 엔드포인트를 삭제하시겠습니까?')) return
|
||||
const memo = prompt('삭제 사유를 입력하세요.')?.trim() ?? ''
|
||||
if (memo.length < 3) return
|
||||
try {
|
||||
await deleteSttEndpoint(id)
|
||||
await deleteSttEndpointClient(id, memo)
|
||||
setSttEndpoints((prev) => prev.filter((ep) => ep.id !== id))
|
||||
} catch (err) {
|
||||
alert('Error: ' + (err instanceof Error ? err.message : String(err)))
|
||||
|
|
@ -326,15 +268,16 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
|
||||
// ── LLM Handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
const applyLlmPreset = (presetKey: string) => {
|
||||
const p = PRESET_LLM_MODELS.find((m) => m.modelId === presetKey)
|
||||
if (!p) return
|
||||
setLlmModelId(p.modelId)
|
||||
setLlmModelName(p.modelName)
|
||||
setLlmProvider(p.provider)
|
||||
setLlmEndpointUrl(p.endpointUrl)
|
||||
setLlmPromptCost(p.promptCost)
|
||||
setLlmCompletionCost(p.completionCost)
|
||||
const openAddLlmModal = () => {
|
||||
setLlmModelId('')
|
||||
setLlmModelName('')
|
||||
setLlmProvider('Custom')
|
||||
setLlmEndpointUrl('')
|
||||
setLlmApiKey('')
|
||||
setLlmPromptCost('0')
|
||||
setLlmCompletionCost('0')
|
||||
setLlmMemo('')
|
||||
setLlmModalOpen(true)
|
||||
}
|
||||
|
||||
const handleAddLlmEndpoint = async (e: React.FormEvent) => {
|
||||
|
|
@ -342,26 +285,23 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
setLlmLoading(true)
|
||||
|
||||
try {
|
||||
const newEp: ModelEndpoint = {
|
||||
id: Date.now(),
|
||||
await createModelEndpointClient({
|
||||
modelId: llmModelId,
|
||||
modelName: llmModelName,
|
||||
provider: llmProvider as ModelEndpoint['provider'],
|
||||
provider: llmProvider,
|
||||
endpointUrl: llmEndpointUrl,
|
||||
apiKey: llmApiKey ? '••••••••' : '',
|
||||
apiKey: llmApiKey,
|
||||
costPer1kPromptTokens: parseFloat(llmPromptCost),
|
||||
costPer1kCompletionTokens: parseFloat(llmCompletionCost),
|
||||
latencyMs: 150,
|
||||
isActive: true,
|
||||
isDefault: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
setLlmEndpoints((prev) => [...prev, newEp])
|
||||
memo: llmMemo.trim(),
|
||||
})
|
||||
await loadData()
|
||||
setLlmModalOpen(false)
|
||||
setLlmModelId('')
|
||||
setLlmModelName('')
|
||||
setLlmEndpointUrl('')
|
||||
setLlmApiKey('')
|
||||
setLlmMemo('')
|
||||
} catch (err) {
|
||||
alert('Error: ' + (err instanceof Error ? err.message : String(err)))
|
||||
} finally {
|
||||
|
|
@ -369,12 +309,16 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
}
|
||||
}
|
||||
|
||||
const handlePingLlm = (_id: number, modelKey: string) => {
|
||||
setLlmPingStatus((prev) => ({ ...prev, [modelKey]: 'Testing...' }))
|
||||
setTimeout(() => {
|
||||
const lat = Math.floor(Math.random() * 80 + 40)
|
||||
setLlmPingStatus((prev) => ({ ...prev, [modelKey]: `OK • ${lat}ms` }))
|
||||
}, 500)
|
||||
const handleDeleteLlm = async (id: number) => {
|
||||
if (!confirm('이 LLM 엔드포인트를 삭제하시겠습니까?')) return
|
||||
const memo = prompt('삭제 사유를 입력하세요.')?.trim() ?? ''
|
||||
if (memo.length < 3) return
|
||||
try {
|
||||
await deleteModelEndpointClient(id, memo)
|
||||
setLlmEndpoints((current) => current.filter((endpoint) => endpoint.id !== id))
|
||||
} catch (error) {
|
||||
alert('Error: ' + (error instanceof Error ? error.message : String(error)))
|
||||
}
|
||||
}
|
||||
|
||||
const defaultStt = sttEndpoints.find((e) => e.isDefault) || sttEndpoints[0]
|
||||
|
|
@ -411,7 +355,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
color: C.bright,
|
||||
letterSpacing: '-0.02em',
|
||||
m: 0,
|
||||
|
|
@ -432,7 +376,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
mt: 0.25,
|
||||
}}
|
||||
>
|
||||
DYNAMIC ROUTING • AUTO FAILOVER • TOKEN & PER-MINUTE BILLING • INSTANT DEFAULT SWITCH
|
||||
Dynamic routing, auto failover, token and per-minute billing, instant default switch
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -443,13 +387,19 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
+ Add STT Provider Endpoint
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="contained" onClick={() => setLlmModalOpen(true)} sx={primaryButtonSx}>
|
||||
<Button variant="contained" onClick={openAddLlmModal} sx={primaryButtonSx}>
|
||||
+ Add LLM Model Endpoint
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{dataError && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{dataError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<Box sx={{ borderBottom: `1px solid ${C.border}`, mb: 3 }}>
|
||||
<Tabs
|
||||
|
|
@ -469,7 +419,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
color: activeTab === 'stt' ? C.bright : C.dim,
|
||||
textTransform: 'none',
|
||||
px: 3,
|
||||
|
|
@ -483,7 +433,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
color: activeTab === 'llm' ? C.bright : C.dim,
|
||||
textTransform: 'none',
|
||||
px: 3,
|
||||
|
|
@ -519,7 +469,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
</Box>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 500, color: C.bright }}>
|
||||
Active Default Cloud STT: {defaultStt.name}
|
||||
</Typography>
|
||||
<Box component="span" sx={statusBadgeSx('green')}>
|
||||
|
|
@ -545,7 +495,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
|
||||
Configured Cloud Transcription Providers
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim }}>
|
||||
|
|
@ -581,7 +531,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
) : (
|
||||
sttEndpoints.map((ep) => (
|
||||
<Box component="tr" key={ep.id} sx={{ bgcolor: ep.isDefault ? 'rgba(59, 130, 246, 0.05)' : 'transparent' }}>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight, fontWeight: 700 }}>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight, fontWeight: 500 }}>
|
||||
#{ep.fallbackPriority}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontWeight: 600, color: C.bright }}>
|
||||
|
|
@ -606,7 +556,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
</Box>
|
||||
<Box component="td">
|
||||
{ep.isDefault ? (
|
||||
<Box component="span" sx={{ px: 1, py: 0.3, borderRadius: '4px', bgcolor: 'rgba(34, 197, 94, 0.2)', color: C.green400, fontSize: '10px', fontWeight: 700 }}>
|
||||
<Box component="span" sx={{ px: 1, py: 0.3, borderRadius: '4px', bgcolor: 'rgba(34, 197, 94, 0.2)', color: C.green400, fontSize: '10px', fontWeight: 500 }}>
|
||||
⭐ DEFAULT
|
||||
</Box>
|
||||
) : (
|
||||
|
|
@ -691,7 +641,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
{activeTab === 'llm' && (
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
|
||||
Active AI Reasoning & Action Endpoints
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
|
|
@ -757,8 +707,8 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
<Box sx={{ display: 'inline-flex', gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => handlePingLlm(ep.id, ep.modelId)}
|
||||
color="error"
|
||||
onClick={() => handleDeleteLlm(ep.id)}
|
||||
sx={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: '10px',
|
||||
|
|
@ -769,7 +719,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
'&:hover': { bgcolor: 'rgba(59, 130, 246, 0.15)', borderColor: C.accentLight },
|
||||
}}
|
||||
>
|
||||
{llmPingStatus[ep.modelId] || '⚡ Ping'}
|
||||
Delete
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -798,35 +748,44 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ fontFamily: FONT_SANS, fontWeight: 700, color: C.bright, borderBottom: `1px solid ${C.border}`, px: 3, py: 2.5 }}>
|
||||
<DialogTitle sx={{ fontFamily: FONT_SANS, fontWeight: 500, color: C.bright, borderBottom: `1px solid ${C.border}`, px: 3, py: 2.5 }}>
|
||||
{sttEditingId ? 'Edit STT Provider Endpoint' : 'Add Cloud STT Provider Endpoint'}
|
||||
</DialogTitle>
|
||||
<Box component="form" onSubmit={handleSaveSttEndpoint}>
|
||||
<DialogContent sx={{ px: 3, py: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{/* Quick Preset Selector */}
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel sx={{ color: C.dim, fontFamily: FONT_SANS }}>Load Preset Template</InputLabel>
|
||||
<Select
|
||||
label="Load Preset Template"
|
||||
defaultValue=""
|
||||
onChange={(e) => applySttPreset(e.target.value)}
|
||||
sx={{
|
||||
bgcolor: 'rgba(10, 17, 31, 0.7)',
|
||||
color: C.bright,
|
||||
borderRadius: '10px',
|
||||
fontFamily: FONT_SANS,
|
||||
'& fieldset': { borderColor: C.border },
|
||||
'&:hover fieldset': { borderColor: C.borderHl },
|
||||
}}
|
||||
>
|
||||
{PRESET_STT_PROVIDERS.map((p) => (
|
||||
<MenuItem key={p.name} value={p.name} sx={{ fontFamily: FONT_SANS, fontSize: '13px' }}>
|
||||
{p.name} ({p.providerType.toUpperCase()}) — ${p.costPerMinute}/min
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{sttEditingId === null && (
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel sx={{ color: C.dim }}>⚡ Load Preset Template (optional)</InputLabel>
|
||||
<Select
|
||||
value=""
|
||||
label="⚡ Load Preset Template (optional)"
|
||||
onChange={(e) => {
|
||||
const preset = PRESET_STT_PROVIDERS.find((p) => p.name === e.target.value)
|
||||
if (!preset) return
|
||||
setSttName(preset.name)
|
||||
setSttProviderType(preset.providerType)
|
||||
setSttEndpointUrl(preset.endpointUrl)
|
||||
setSttModelId(preset.modelId)
|
||||
setSttMethod(preset.method)
|
||||
setSttLanguage(preset.language)
|
||||
setSttCostPerMinute(String(preset.costPerMinute))
|
||||
if (preset.prompt) setSttPrompt(preset.prompt)
|
||||
}}
|
||||
sx={{
|
||||
bgcolor: 'rgba(10, 17, 31, 0.7)',
|
||||
color: C.bright,
|
||||
borderRadius: '10px',
|
||||
'& fieldset': { borderColor: C.border },
|
||||
}}
|
||||
>
|
||||
{PRESET_STT_PROVIDERS.map((preset) => (
|
||||
<MenuItem key={preset.name} value={preset.name}>
|
||||
{preset.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1.2fr 0.8fr' }, gap: 2 }}>
|
||||
<TextField
|
||||
label="Provider Name"
|
||||
|
|
@ -984,6 +943,16 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
/>
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
label="Audit memo"
|
||||
value={sttMemo}
|
||||
onChange={(event) => setSttMemo(event.target.value)}
|
||||
required
|
||||
helperText="변경 사유를 3자 이상 입력하세요."
|
||||
size="small"
|
||||
sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'rgba(10, 17, 31, 0.7)', color: C.bright, borderRadius: '10px' } }}
|
||||
/>
|
||||
|
||||
{/* Test Connection Inside Modal */}
|
||||
<Box sx={{ p: 2, borderRadius: '12px', bgcolor: 'rgba(10, 17, 31, 0.6)', border: `1px solid ${C.border}`, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
|
|
@ -1029,7 +998,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
<Button onClick={() => setSttModalOpen(false)} sx={{ color: C.dim, textTransform: 'none' }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="contained" disabled={sttLoading} sx={primaryButtonSx}>
|
||||
<Button type="submit" variant="contained" disabled={sttLoading || sttMemo.trim().length < 3} sx={primaryButtonSx}>
|
||||
{sttLoading ? 'Saving...' : 'Save STT Provider'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
|
|
@ -1052,33 +1021,40 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ fontFamily: FONT_SANS, fontWeight: 700, color: C.bright, borderBottom: `1px solid ${C.border}`, px: 3, py: 2.5 }}>
|
||||
<DialogTitle sx={{ fontFamily: FONT_SANS, fontWeight: 500, color: C.bright, borderBottom: `1px solid ${C.border}`, px: 3, py: 2.5 }}>
|
||||
Add Reasoning Model Endpoint
|
||||
</DialogTitle>
|
||||
<Box component="form" onSubmit={handleAddLlmEndpoint}>
|
||||
<DialogContent sx={{ px: 3, py: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel sx={{ color: C.dim, fontFamily: FONT_SANS }}>Load Preset Template</InputLabel>
|
||||
<InputLabel sx={{ color: C.dim }}>⚡ Load Preset Template (optional)</InputLabel>
|
||||
<Select
|
||||
label="Load Preset Template"
|
||||
defaultValue=""
|
||||
onChange={(e) => applyLlmPreset(e.target.value)}
|
||||
value=""
|
||||
label="⚡ Load Preset Template (optional)"
|
||||
onChange={(e) => {
|
||||
const preset = PRESET_LLM_MODELS.find((p) => p.modelId === e.target.value)
|
||||
if (!preset) return
|
||||
setLlmModelId(preset.modelId)
|
||||
setLlmModelName(preset.modelName)
|
||||
setLlmProvider(preset.provider)
|
||||
setLlmEndpointUrl(preset.endpointUrl)
|
||||
setLlmPromptCost(preset.promptCost)
|
||||
setLlmCompletionCost(preset.completionCost)
|
||||
}}
|
||||
sx={{
|
||||
bgcolor: 'rgba(10, 17, 31, 0.7)',
|
||||
color: C.bright,
|
||||
borderRadius: '10px',
|
||||
fontFamily: FONT_SANS,
|
||||
'& fieldset': { borderColor: C.border },
|
||||
}}
|
||||
>
|
||||
{PRESET_LLM_MODELS.map((p) => (
|
||||
<MenuItem key={p.modelId} value={p.modelId} sx={{ fontFamily: FONT_SANS, fontSize: '13px' }}>
|
||||
{p.modelName} ({p.provider})
|
||||
{PRESET_LLM_MODELS.map((preset) => (
|
||||
<MenuItem key={preset.modelId} value={preset.modelId}>
|
||||
{preset.modelName}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label="Model ID"
|
||||
value={llmModelId}
|
||||
|
|
@ -1152,12 +1128,21 @@ export default function ServiceModelsPage(): React.ReactElement {
|
|||
sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'rgba(10, 17, 31, 0.7)', color: C.bright, borderRadius: '10px', fontFamily: FONT_MONO } }}
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Audit memo"
|
||||
value={llmMemo}
|
||||
onChange={(event) => setLlmMemo(event.target.value)}
|
||||
required
|
||||
helperText="생성 사유를 3자 이상 입력하세요."
|
||||
size="small"
|
||||
sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'rgba(10, 17, 31, 0.7)', color: C.bright, borderRadius: '10px' } }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 3, pt: 1, borderTop: `1px solid ${C.border}` }}>
|
||||
<Button onClick={() => setLlmModalOpen(false)} sx={{ color: C.dim, textTransform: 'none' }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="contained" disabled={llmLoading} sx={primaryButtonSx}>
|
||||
<Button type="submit" variant="contained" disabled={llmLoading || llmMemo.trim().length < 3} sx={primaryButtonSx}>
|
||||
{llmLoading ? 'Saving...' : 'Save Endpoint'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
|
|
|
|||
|
|
@ -3,21 +3,61 @@
|
|||
|
||||
import { Box, Typography, Button } from '@mui/material'
|
||||
import { fetchServerStats } from '@/lib/api-server'
|
||||
import { isSupabaseAdminConfigured } from '@/lib/supabase-admin'
|
||||
import { fetchSubscriptionRevenue, type SubscriptionRevenue } from '@/lib/subscription-metrics'
|
||||
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
|
||||
import { DoubleBezelCard, StatRing, TactileBadge } from '@d3ro/ui/components/ds'
|
||||
import { DashboardSimulator } from '@/components/dashboard-simulator'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
||||
const stats = await fetchServerStats()
|
||||
const nonOperationalNodes = stats.nodes.filter((node) => node.status !== 'operational')
|
||||
|
||||
let revenue: SubscriptionRevenue | null = null
|
||||
if (isSupabaseAdminConfigured()) {
|
||||
try {
|
||||
revenue = await fetchSubscriptionRevenue()
|
||||
} catch {
|
||||
revenue = null
|
||||
}
|
||||
}
|
||||
|
||||
const revenueCards = revenue
|
||||
? [
|
||||
{
|
||||
title: 'Annual Recurring Revenue',
|
||||
value: `$${revenue.arrUsd.toLocaleString()}`,
|
||||
subtext: 'Active subscriptions × 12 months',
|
||||
color: 'green' as const,
|
||||
badge: 'ARR',
|
||||
badgeColor: 'green' as const,
|
||||
},
|
||||
{
|
||||
title: 'Monthly Recurring Revenue',
|
||||
value: `$${revenue.mrrUsd.toLocaleString()}`,
|
||||
subtext: `Pro ${revenue.tierBreakdown.pro.toLocaleString()} · Pro+ ${revenue.tierBreakdown.pro_plus.toLocaleString()}`,
|
||||
color: 'blue' as const,
|
||||
badge: 'MRR',
|
||||
badgeColor: 'blue' as const,
|
||||
},
|
||||
{
|
||||
title: 'Active Subscriptions',
|
||||
value: revenue.activeCount.toLocaleString(),
|
||||
subtext: 'Supabase subscriptions with status = active',
|
||||
color: 'purple' as const,
|
||||
badge: 'BILLING',
|
||||
badgeColor: 'purple' as const,
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const bentoCards = [
|
||||
{
|
||||
title: 'Annual Recurring Revenue (ARR)',
|
||||
value: `$${stats.arrUsd.toLocaleString()}`,
|
||||
subtext: `MRR: $${stats.mrrUsd.toLocaleString()} • +18.4% MoM Growth`,
|
||||
title: 'Backend Uptime',
|
||||
value: `${Math.floor(stats.serverUptimeSeconds / 3600).toLocaleString()}h`,
|
||||
subtext: 'Measured by the active .NET API process',
|
||||
color: 'purple' as const,
|
||||
badge: 'REVENUE',
|
||||
badge: 'RUNTIME',
|
||||
badgeColor: 'purple' as const,
|
||||
icon: (
|
||||
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
|
@ -26,11 +66,11 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
|||
),
|
||||
},
|
||||
{
|
||||
title: 'Active Voice & Meeting Sessions',
|
||||
title: 'Active Admin Accounts Today',
|
||||
value: `${stats.activeUsersToday.toLocaleString()} Active`,
|
||||
subtext: `${stats.totalUsers.toLocaleString()} Total Users • 18 Realtime Streams`,
|
||||
subtext: `${stats.totalUsers.toLocaleString()} administrator accounts`,
|
||||
color: 'blue' as const,
|
||||
badge: 'VOICE STREAMS',
|
||||
badge: 'AUTH',
|
||||
badgeColor: 'blue' as const,
|
||||
icon: (
|
||||
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
|
@ -52,11 +92,11 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
|||
),
|
||||
},
|
||||
{
|
||||
title: 'Speaker Diarization Accuracy',
|
||||
value: `${stats.pipelines.meetingIntelligence.speakerAccuracyPercent}%`,
|
||||
subtext: `${stats.pipelines.meetingIntelligence.templatesGeneratedToday} Meeting Docs • 42 Mindmaps`,
|
||||
title: 'Recorded Backend Errors',
|
||||
value: stats.errorCount.toLocaleString(),
|
||||
subtext: 'Persisted server error log entries',
|
||||
color: 'orange' as const,
|
||||
badge: 'PHASE 15.5',
|
||||
badge: 'ERROR LOG',
|
||||
badgeColor: 'orange' as const,
|
||||
icon: (
|
||||
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
|
@ -98,7 +138,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
|||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
color: C.bright,
|
||||
letterSpacing: '-0.02em',
|
||||
m: 0,
|
||||
|
|
@ -107,7 +147,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
|||
Unified Dashboard Overview
|
||||
</Typography>
|
||||
<TactileBadge ledColor="green" ledPulse tone="success" mono>
|
||||
ONLINE • v0.2.1-alpha
|
||||
BACKEND CONNECTED
|
||||
</TactileBadge>
|
||||
</Box>
|
||||
<Typography
|
||||
|
|
@ -119,7 +159,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
|||
mt: 0.25,
|
||||
}}
|
||||
>
|
||||
REALTIME AI TELEMETRY • ARR & SUBSCRIPTION METRICS • PIPELINE HEALTH
|
||||
Backend runtime counters, error ledger, reported node health
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -149,6 +189,70 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
|||
|
||||
{/* Main Content Area */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3.5 }}>
|
||||
{/* Revenue KPI Row */}
|
||||
{revenueCards.length > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: 'repeat(3, 1fr)' },
|
||||
gap: 2.5,
|
||||
}}
|
||||
>
|
||||
{revenueCards.map((card) => (
|
||||
<DoubleBezelCard
|
||||
key={card.title}
|
||||
interactive
|
||||
bezelPadding="5px"
|
||||
innerPadding="20px"
|
||||
sx={{ height: '100%' }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||||
<StatRing color={card.color} size={46}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '13px', fontWeight: 500 }}>$</Typography>
|
||||
</StatRing>
|
||||
<Box component="span" sx={statusBadgeSx(card.badgeColor)}>
|
||||
{card.badge}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
color: C.dim,
|
||||
letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
{card.title}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: '26px',
|
||||
fontWeight: 500,
|
||||
color: C.bright,
|
||||
my: 0.75,
|
||||
}}
|
||||
>
|
||||
{card.value}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '11px',
|
||||
color: C.text,
|
||||
}}
|
||||
>
|
||||
{card.subtext}
|
||||
</Typography>
|
||||
</DoubleBezelCard>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Executive Bento Grid */}
|
||||
<Box
|
||||
sx={{
|
||||
|
|
@ -178,10 +282,9 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
|||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
fontWeight: 500,
|
||||
color: C.dim,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
{card.title}
|
||||
|
|
@ -191,7 +294,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
|||
sx={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: '26px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
color: C.bright,
|
||||
my: 0.75,
|
||||
}}
|
||||
|
|
@ -216,15 +319,19 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
|||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
|
||||
System Nodes & Pipeline Topology
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
6 NODES HEALTHY • ZERO SERVICE DEGRADATION DETECTED
|
||||
{stats.nodes.length === 0
|
||||
? 'NODE TELEMETRY NOT REPORTED'
|
||||
: `${stats.nodes.length} REPORTED • ${nonOperationalNodes.length} NON-OPERATIONAL`}
|
||||
</Typography>
|
||||
</Box>
|
||||
<TactileBadge tone="success" mono>
|
||||
ALL OPERATIONAL
|
||||
<TactileBadge tone="mono" mono>
|
||||
{stats.nodes.length === 0
|
||||
? 'UNAVAILABLE'
|
||||
: nonOperationalNodes.length === 0 ? 'ALL REPORTED OPERATIONAL' : 'ATTENTION REQUIRED'}
|
||||
</TactileBadge>
|
||||
</Box>
|
||||
|
||||
|
|
@ -235,7 +342,11 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
|||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{stats.nodes.map((node) => (
|
||||
{stats.nodes.length === 0 ? (
|
||||
<Typography sx={{ gridColumn: '1 / -1', py: 3, textAlign: 'center', color: C.dim, fontFamily: FONT_MONO, fontSize: '12px' }}>
|
||||
No node-health telemetry has been reported by the backend.
|
||||
</Typography>
|
||||
) : stats.nodes.map((node) => (
|
||||
<Box
|
||||
key={node.id}
|
||||
sx={{
|
||||
|
|
@ -252,7 +363,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
|||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', fontWeight: 500, color: C.bright }}>
|
||||
{node.name}
|
||||
</Typography>
|
||||
<Box
|
||||
|
|
@ -260,8 +371,8 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
|||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: '#10b981',
|
||||
boxShadow: '0 0 8px #10b981',
|
||||
bgcolor: node.status === 'operational' ? '#10b981' : node.status === 'degraded' ? '#f59e0b' : '#ef4444',
|
||||
boxShadow: `0 0 8px ${node.status === 'operational' ? '#10b981' : node.status === 'degraded' ? '#f59e0b' : '#ef4444'}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
|
@ -274,8 +385,8 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
|||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>
|
||||
Latency: <strong style={{ color: C.bright }}>{node.latencyMs}ms</strong>
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.green400 }}>
|
||||
{node.uptimePercent}% Up
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: node.status === 'operational' ? C.green400 : node.status === 'degraded' ? C.orange400 : C.red400 }}>
|
||||
{node.uptimePercent}% · {node.status.toUpperCase()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -283,17 +394,14 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
|||
</Box>
|
||||
</DoubleBezelCard>
|
||||
|
||||
{/* Live Audio & Voice Intelligence Simulator Widget */}
|
||||
<DashboardSimulator />
|
||||
|
||||
{/* Server Operational Telemetry Logs */}
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 500, color: C.bright }}>
|
||||
Operational Telemetry & Server Logs
|
||||
</Typography>
|
||||
<TactileBadge tone="mono" mono>
|
||||
AUTO REFRESH (30s)
|
||||
SERVER SNAPSHOT
|
||||
</TactileBadge>
|
||||
</Box>
|
||||
|
||||
|
|
@ -312,7 +420,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
|||
{stats.recentErrors.length === 0 ? (
|
||||
<Box component="tr">
|
||||
<Box component="td" colSpan={5} sx={{ textAlign: 'center', color: C.green400, py: 3 }}>
|
||||
✓ NO OPERATIONAL ERRORS — ALL C# .NET API NODES HEALTHY (100% SUCCESS RATE)
|
||||
No backend errors have been recorded.
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -8,6 +8,18 @@ import { StatRing, TactileBadge, DoubleBezelCard } from '@d3ro/ui/components/ds'
|
|||
|
||||
export default async function PipelinesPage(): Promise<React.ReactElement> {
|
||||
const stats = await fetchServerStats()
|
||||
if (!stats.pipelines) {
|
||||
return (
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Typography component="h1" sx={{ fontFamily: FONT_SANS, fontSize: '20px', fontWeight: 500, color: C.bright, mb: 1 }}>
|
||||
Pipeline telemetry unavailable
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim, lineHeight: 1.7 }}>
|
||||
The backend does not currently expose measured pipeline telemetry. No simulated engine, latency, accuracy, or capacity values are shown.
|
||||
</Typography>
|
||||
</DoubleBezelCard>
|
||||
)
|
||||
}
|
||||
const { whisper, ollama, realtimeVoice, ragVector, meetingIntelligence } = stats.pipelines
|
||||
|
||||
return (
|
||||
|
|
@ -42,7 +54,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
|||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
color: C.bright,
|
||||
letterSpacing: '-0.02em',
|
||||
m: 0,
|
||||
|
|
@ -63,7 +75,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
|||
mt: 0.25,
|
||||
}}
|
||||
>
|
||||
LOCAL WHISPER • OLLAMA V0.32.1 • GPT-REALTIME 2.1 • VECTOR RAG • DIARIZATION
|
||||
Local Whisper, Ollama v0.32.1, GPT-Realtime 2.1, Vector RAG, Diarization
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -103,7 +115,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
|||
</svg>
|
||||
</StatRing>
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
|
||||
Faster-Whisper STT Sidecar
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight }}>
|
||||
|
|
@ -119,19 +131,19 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
|||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5, mb: 2.5 }}>
|
||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>AVG LATENCY</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.bright }}>
|
||||
{whisper.avgLatencyMs}ms
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>SPEEDUP FACTOR</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.green400 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.green400 }}>
|
||||
{whisper.speedupFactor}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>GPU VRAM</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.cyanLight }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.cyanLight }}>
|
||||
{whisper.gpuVramUsage}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
|
@ -156,7 +168,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
|||
</svg>
|
||||
</StatRing>
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
|
||||
Bundled Ollama Runtime
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.purple400 }}>
|
||||
|
|
@ -172,19 +184,19 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
|||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5, mb: 2.5 }}>
|
||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>THROUGHPUT</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.bright }}>
|
||||
{ollama.tokensPerSecond} tok/s
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>CONTEXT LIMIT</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.cyanLight }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.cyanLight }}>
|
||||
{ollama.activeContextLimit}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>VRAM OCCUPANCY</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.purple400 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.purple400 }}>
|
||||
{ollama.vramAllocated}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
|
@ -212,7 +224,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
|||
</svg>
|
||||
</StatRing>
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
|
||||
GPT-Realtime 2.1 Live Engine
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.orange400 }}>
|
||||
|
|
@ -228,19 +240,19 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
|||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5, mb: 2.5 }}>
|
||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>LIVE STREAMS</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.orange400 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.orange400 }}>
|
||||
{realtimeVoice.activeStreams} Active
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>AUDIO RTT</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.bright }}>
|
||||
{realtimeVoice.avgAudioRttMs}ms
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>LOCAL FALLBACK</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.green400 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.green400 }}>
|
||||
{realtimeVoice.localFallbackRate}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
|
@ -265,7 +277,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
|||
</svg>
|
||||
</StatRing>
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
|
||||
SQLite Vector RAG Engine
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.green400 }}>
|
||||
|
|
@ -281,19 +293,19 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
|||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5, mb: 2.5 }}>
|
||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>INDEXED DOCS</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.bright }}>
|
||||
{ragVector.indexedDocuments.toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>VECTOR CHUNKS</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.cyanLight }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.cyanLight }}>
|
||||
{ragVector.totalVectorChunks.toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>SEARCH HIT RATE</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.green400 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.green400 }}>
|
||||
{ragVector.topHitRatePercent}%
|
||||
</Typography>
|
||||
</Box>
|
||||
|
|
@ -319,7 +331,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
|||
</svg>
|
||||
</StatRing>
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '18px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '18px', fontWeight: 500, color: C.bright }}>
|
||||
Meeting Intelligence & Speaker Diarization (Phase 14~15.5)
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.accentLight }}>
|
||||
|
|
@ -335,7 +347,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
|||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)', md: 'repeat(4, 1fr)' }, gap: 2, mb: 3 }}>
|
||||
<Box sx={{ p: 2, borderRadius: '12px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>SPEAKER ACCURACY</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 700, color: C.green400 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 500, color: C.green400 }}>
|
||||
{meetingIntelligence.speakerAccuracyPercent}%
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
|
||||
|
|
@ -344,7 +356,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
|||
</Box>
|
||||
<Box sx={{ p: 2, borderRadius: '12px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>ACTIVE MEETINGS</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 700, color: C.cyanLight }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 500, color: C.cyanLight }}>
|
||||
{meetingIntelligence.activeMeetingSessions} Live
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
|
||||
|
|
@ -353,7 +365,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
|||
</Box>
|
||||
<Box sx={{ p: 2, borderRadius: '12px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>TEMPLATES TODAY</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 700, color: C.purple400 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 500, color: C.purple400 }}>
|
||||
{meetingIntelligence.templatesGeneratedToday} Docs
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
|
||||
|
|
@ -362,7 +374,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
|||
</Box>
|
||||
<Box sx={{ p: 2, borderRadius: '12px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>MINDMAP EXPORTS</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 700, color: C.orange400 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 500, color: C.orange400 }}>
|
||||
{meetingIntelligence.mindmapsExported} Maps
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
|
||||
|
|
|
|||
|
|
@ -1,397 +1,353 @@
|
|||
// apps/admin/src/app/(admin)/releases/page.tsx
|
||||
// D3RO Voice Admin CRM — Release & Distribution Management Hub
|
||||
// D3RO Voice Admin CRM — Release & Distribution Hub (Forgejo live feed)
|
||||
|
||||
'use client'
|
||||
import { Box, Typography, Button } from '@mui/material'
|
||||
import Link from 'next/link'
|
||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
|
||||
import { requireManager } from '@/lib/admin-guard'
|
||||
import { fetchReleaseHub, type ReleaseAssetPlatform, type ReleaseHub } from '@/lib/forgejo-releases'
|
||||
import { ChecksumCopy } from '@/components/checksum-copy'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Box, Typography, Button, LinearProgress, Switch } from '@mui/material'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
ArrowUpRight,
|
||||
} from 'lucide-react'
|
||||
import { C, FONT_SANS, FONT_MONO } from '@/lib/console-theme'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface ReleaseAsset {
|
||||
id: string
|
||||
name: string
|
||||
os: 'Windows' | 'macOS' | 'Linux' | 'Feed'
|
||||
version: string
|
||||
sizeMb: number
|
||||
downloads: number
|
||||
sha256: string
|
||||
status: 'active' | 'deprecated' | 'archived'
|
||||
url: string
|
||||
const PLATFORM_LABEL: Record<ReleaseAssetPlatform, string> = {
|
||||
windows: 'Windows',
|
||||
macos: 'macOS',
|
||||
android: 'Android',
|
||||
linux: 'Linux',
|
||||
feed: 'Update Feed',
|
||||
other: 'Other'
|
||||
}
|
||||
|
||||
const INITIAL_ASSETS: ReleaseAsset[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'D3RO-Voice-Setup-1.0.0-x64.exe',
|
||||
os: 'Windows',
|
||||
version: '1.0.0',
|
||||
sizeMb: 102.1,
|
||||
downloads: 1420,
|
||||
sha256: 'b0ac051443151a2e34e8192f1fcf795586bbafca6eb21f01a79170c079a53ba2',
|
||||
status: 'active',
|
||||
url: '/releases/1.0.0/D3RO-Voice-Setup-1.0.0-x64.exe',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'D3RO-Voice-1.0.0-arm64.dmg',
|
||||
os: 'macOS',
|
||||
version: '1.0.0',
|
||||
sizeMb: 98.4,
|
||||
downloads: 890,
|
||||
sha256: 'd9f28a391c49b1a03982e0192847192837491823749182374918237491823749',
|
||||
status: 'active',
|
||||
url: '/releases/1.0.0/D3RO-Voice-1.0.0-arm64.dmg',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'latest.yml',
|
||||
os: 'Feed',
|
||||
version: '1.0.0',
|
||||
sizeMb: 0.01,
|
||||
downloads: 4820,
|
||||
sha256: '795b7230bec047284785f480026d51b9247d8618e083d88cfda786d1894ca367',
|
||||
status: 'active',
|
||||
url: '/releases/latest.yml',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'D3RO-Voice-Setup-0.2.1-alpha-x64.exe',
|
||||
os: 'Windows',
|
||||
version: '0.2.1-alpha',
|
||||
sizeMb: 99.8,
|
||||
downloads: 620,
|
||||
sha256: '1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b',
|
||||
status: 'deprecated',
|
||||
url: '/releases/0.2.1-alpha/D3RO-Voice-Setup-0.2.1-alpha-x64.exe',
|
||||
},
|
||||
]
|
||||
const PLATFORM_BADGE: Record<ReleaseAssetPlatform, 'blue' | 'purple' | 'green' | 'orange' | 'cyan'> = {
|
||||
windows: 'blue',
|
||||
macos: 'purple',
|
||||
android: 'green',
|
||||
linux: 'orange',
|
||||
feed: 'cyan',
|
||||
other: 'orange'
|
||||
}
|
||||
|
||||
export default function ReleasesManagementPage(): React.ReactElement {
|
||||
const [assets] = useState<ReleaseAsset[]>(INITIAL_ASSETS)
|
||||
const [rolloutPercent, setRolloutPercent] = useState<number>(100)
|
||||
const [forceUpdateEnabled, setForceUpdateEnabled] = useState<boolean>(false)
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
function formatSize(sizeBytes: number): string {
|
||||
if (sizeBytes <= 0) return '—'
|
||||
if (sizeBytes < 1024 * 1024) return `${(sizeBytes / 1024).toFixed(1)} KB`
|
||||
return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string, id: string): void => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedId(id)
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
})
|
||||
}
|
||||
function formatDate(value: string): string {
|
||||
if (!value) return '—'
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? '—' : parsed.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const totalDownloads = assets.reduce((acc, curr) => acc + curr.downloads, 0)
|
||||
function HeaderBar({ feedLive, repoHtmlUrl }: { feedLive: boolean; repoHtmlUrl: string }): React.ReactElement {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
...panelSx,
|
||||
minHeight: 84,
|
||||
px: { xs: 2.5, md: 4 },
|
||||
py: 2,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 4,
|
||||
height: 32,
|
||||
borderRadius: '999px',
|
||||
background: `linear-gradient(180deg, ${C.cyan} 0%, ${C.accent} 50%, ${C.purple} 100%)`
|
||||
}}
|
||||
/>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Typography
|
||||
component="h1"
|
||||
sx={{ fontFamily: FONT_SANS, fontSize: '20px', fontWeight: 500, color: C.bright, letterSpacing: '-0.02em', m: 0 }}
|
||||
>
|
||||
Release & Distribution Hub
|
||||
</Typography>
|
||||
{feedLive ? (
|
||||
<TactileBadge ledColor="green" ledPulse tone="success" mono>
|
||||
LIVE FORGEJO FEED
|
||||
</TactileBadge>
|
||||
) : (
|
||||
<TactileBadge tone="warning" mono>
|
||||
FEED UNREACHABLE
|
||||
</TactileBadge>
|
||||
)}
|
||||
</Box>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, letterSpacing: '0.04em', mt: 0.25 }}>
|
||||
Desktop & mobile installers, SHA-256 integrity, download telemetry
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
component={Link}
|
||||
href={`${repoHtmlUrl}/releases`}
|
||||
target="_blank"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: C.text,
|
||||
borderColor: C.border,
|
||||
textTransform: 'none',
|
||||
borderRadius: '10px',
|
||||
'&:hover': { borderColor: C.accentLight, color: C.bright }
|
||||
}}
|
||||
>
|
||||
Forgejo Releases ↗
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
href="https://d3ro.chanpaca.net/download.html"
|
||||
target="_blank"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: C.cyanLight,
|
||||
borderColor: C.border,
|
||||
textTransform: 'none',
|
||||
borderRadius: '10px',
|
||||
'&:hover': { borderColor: C.cyanLight, color: C.bright }
|
||||
}}
|
||||
>
|
||||
Public Download Page ↗
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function KpiCards({ hub }: { hub: ReleaseHub }): React.ReactElement {
|
||||
const latest = hub.latestStable
|
||||
const latestPlatforms = [...new Set(latest?.assets.map((asset) => asset.platform) ?? [])].filter(
|
||||
(platform) => platform !== 'other'
|
||||
)
|
||||
const cards = [
|
||||
{
|
||||
title: 'Total Asset Downloads',
|
||||
value: hub.totalDownloads.toLocaleString(),
|
||||
subtext: 'Forgejo attachment download counters'
|
||||
},
|
||||
{
|
||||
title: 'Latest Stable Release',
|
||||
value: latest?.tagName ?? '—',
|
||||
subtext: latest ? `Published ${formatDate(latest.publishedAt)}` : 'No stable release published'
|
||||
},
|
||||
{
|
||||
title: 'Published Releases',
|
||||
value: hub.releases.length.toLocaleString(),
|
||||
subtext: `${hub.prereleaseCount.toLocaleString()} pre-release channel builds`
|
||||
},
|
||||
{
|
||||
title: 'Latest Platform Coverage',
|
||||
value: latestPlatforms.length ? latestPlatforms.map((platform) => PLATFORM_LABEL[platform]).join(' · ') : '—',
|
||||
subtext: latest ? `${latest.assets.length.toLocaleString()} packaged artifacts` : 'Awaiting first artifact upload'
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<Box sx={{ p: { xs: 2, md: 3 }, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 0.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '24px', fontWeight: 800, color: C.bright }}>
|
||||
Release & Distribution Hub
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.2,
|
||||
py: 0.3,
|
||||
borderRadius: '6px',
|
||||
bgcolor: 'rgba(56, 189, 248, 0.15)',
|
||||
border: '1px solid rgba(56, 189, 248, 0.3)',
|
||||
color: C.cyanLight,
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
v1.0.0 STABLE
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>
|
||||
Manage desktop installer distribution, multi-platform binaries, SHA-256 integrity checks, and auto-update feeds.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Button
|
||||
href="https://git.chanpaca.net/yunchan/d3ro-voice/releases"
|
||||
target="_blank"
|
||||
variant="outlined"
|
||||
startIcon={<ExternalLink size={14} />}
|
||||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: C.text,
|
||||
borderColor: C.border,
|
||||
textTransform: 'none',
|
||||
borderRadius: '10px',
|
||||
'&:hover': { borderColor: C.accentLight, bgcolor: 'rgba(255, 255, 255, 0.05)' },
|
||||
}}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' }, gap: 2 }}>
|
||||
{cards.map((card) => (
|
||||
<Box key={card.title} sx={{ ...panelSx, p: 2.5 }}>
|
||||
<Typography
|
||||
sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mb: 1, textTransform: 'uppercase', letterSpacing: '0.08em' }}
|
||||
>
|
||||
Git Release Tags
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
href="http://localhost:3000/download.html"
|
||||
target="_blank"
|
||||
variant="contained"
|
||||
startIcon={<ArrowUpRight size={14} />}
|
||||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
color: '#000',
|
||||
bgcolor: C.cyanLight,
|
||||
textTransform: 'none',
|
||||
borderRadius: '10px',
|
||||
'&:hover': { bgcolor: '#7dd3fc' },
|
||||
}}
|
||||
>
|
||||
View Public Download Page
|
||||
</Button>
|
||||
{card.title}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '22px', fontWeight: 700, color: C.bright, lineHeight: 1.25 }}>
|
||||
{card.value}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>{card.subtext}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function LatestAssetsPanel({ latest }: { latest: ReleaseHub['latestStable'] }): React.ReactElement {
|
||||
return (
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1.5, mb: 2 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright }}>
|
||||
Latest Binary Packages & Checksums
|
||||
</Typography>
|
||||
{latest && (
|
||||
<Box sx={statusBadgeSx('cyan')}>{latest.tagName}</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: 'repeat(4, 1fr)' }, gap: 2 }}>
|
||||
<Box sx={{ p: 2.5, borderRadius: '16px', bgcolor: C.card, border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 1, textTransform: 'uppercase' }}>
|
||||
Total App Downloads
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '26px', fontWeight: 800, color: C.bright }}>
|
||||
{totalDownloads.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: '#34d399', mt: 0.5 }}>
|
||||
+18.4% WoW (Free & Pro Installs)
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2.5, borderRadius: '16px', bgcolor: C.card, border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 1, textTransform: 'uppercase' }}>
|
||||
Auto-Update Feed Health
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckCircle2 size={20} color="#34d399" />
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 800, color: C.bright }}>
|
||||
200 OK (Live)
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight, mt: 0.5 }}>
|
||||
latest.yml • generic feed
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2.5, borderRadius: '16px', bgcolor: C.card, border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 1, textTransform: 'uppercase' }}>
|
||||
Synology NAS Storage
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '26px', fontWeight: 800, color: C.bright }}>
|
||||
2.4 GB / 8 TB
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
|
||||
/volume1/docker/d3ro-voice
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2.5, borderRadius: '16px', bgcolor: C.card, border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 1, textTransform: 'uppercase' }}>
|
||||
Active Release Version
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '26px', fontWeight: 800, color: C.purple400 }}>
|
||||
1.0.0
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
|
||||
Deployed: 2026-08-20
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Phased Rollout & Control Card */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: '20px',
|
||||
bgcolor: 'rgba(17, 26, 48, 0.7)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
border: `1px solid ${C.border}`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2, mb: 3 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
Phased Rollout & Auto-Update Policy
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim }}>
|
||||
Control automatic background update delivery to client desktop installations.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.text }}>
|
||||
Force Update:
|
||||
</Typography>
|
||||
<Switch
|
||||
checked={forceUpdateEnabled}
|
||||
onChange={(e) => setForceUpdateEnabled(e.target.checked)}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
borderRadius: '8px',
|
||||
bgcolor: 'rgba(34, 197, 94, 0.12)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||
color: '#34d399',
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Rollout: {rolloutPercent}%
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={rolloutPercent}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: '4px',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.05)',
|
||||
'& .MuiLinearProgress-bar': { bgcolor: C.cyanLight },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{[10, 25, 50, 100].map((pct) => (
|
||||
<Button
|
||||
key={pct}
|
||||
size="small"
|
||||
onClick={() => setRolloutPercent(pct)}
|
||||
sx={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: rolloutPercent === pct ? '#000' : C.text,
|
||||
bgcolor: rolloutPercent === pct ? C.cyanLight : 'rgba(255, 255, 255, 0.04)',
|
||||
border: `1px solid ${rolloutPercent === pct ? C.cyanLight : C.border}`,
|
||||
borderRadius: '8px',
|
||||
textTransform: 'none',
|
||||
'&:hover': { bgcolor: rolloutPercent === pct ? '#7dd3fc' : 'rgba(255, 255, 255, 0.08)' },
|
||||
}}
|
||||
>
|
||||
Set {pct}%
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Release Assets Table */}
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: '20px',
|
||||
bgcolor: 'rgba(17, 26, 48, 0.7)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
border: `1px solid ${C.border}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2.5, borderBottom: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 700, color: C.bright }}>
|
||||
Published Binary Packages & Checksums
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{!latest || latest.assets.length === 0 ? (
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>
|
||||
최신 릴리스에 업로드된 아티팩트가 없습니다.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
|
||||
<Box component="table" sx={tableSx}>
|
||||
<Box component="thead">
|
||||
<Box component="tr" sx={{ borderBottom: `1px solid ${C.border}`, bgcolor: 'rgba(0, 0, 0, 0.2)' }}>
|
||||
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Artifact Name</Box>
|
||||
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Platform</Box>
|
||||
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Version</Box>
|
||||
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Size</Box>
|
||||
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Downloads</Box>
|
||||
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>SHA-256 Checksum</Box>
|
||||
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Status</Box>
|
||||
<Box component="tr">
|
||||
<Box component="th">Artifact</Box>
|
||||
<Box component="th">Platform</Box>
|
||||
<Box component="th">Size</Box>
|
||||
<Box component="th">Downloads</Box>
|
||||
<Box component="th">SHA-256</Box>
|
||||
<Box component="th">Link</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box component="tbody">
|
||||
{assets.map((asset) => (
|
||||
<Box
|
||||
component="tr"
|
||||
key={asset.id}
|
||||
sx={{
|
||||
borderBottom: `1px solid ${C.border}`,
|
||||
'&:hover': { bgcolor: 'rgba(255, 255, 255, 0.02)' },
|
||||
}}
|
||||
>
|
||||
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '12px', fontWeight: 600, color: C.bright }}>
|
||||
{latest.assets.map((asset) => (
|
||||
<Box component="tr" key={asset.id}>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
|
||||
{asset.name}
|
||||
</Box>
|
||||
<Box component="td" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '12px', color: C.text }}>
|
||||
{asset.os}
|
||||
<Box component="td">
|
||||
<Box sx={statusBadgeSx(PLATFORM_BADGE[asset.platform])}>{PLATFORM_LABEL[asset.platform]}</Box>
|
||||
</Box>
|
||||
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
|
||||
{asset.version}
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>{formatSize(asset.sizeBytes)}</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
|
||||
{asset.downloadCount.toLocaleString()}
|
||||
</Box>
|
||||
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
|
||||
{asset.sizeMb} MB
|
||||
<Box component="td">
|
||||
{asset.sha256 ? (
|
||||
<ChecksumCopy value={asset.sha256} />
|
||||
) : (
|
||||
<Typography component="span" sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.muted }}>
|
||||
not published
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '12px', fontWeight: 700, color: C.bright }}>
|
||||
{asset.downloads.toLocaleString()}
|
||||
</Box>
|
||||
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<span style={{ maxWidth: '160px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{asset.sha256}
|
||||
</span>
|
||||
<Box
|
||||
onClick={() => copyToClipboard(asset.sha256, asset.id)}
|
||||
sx={{ cursor: 'pointer', color: copiedId === asset.id ? '#34d399' : C.cyanLight, '&:hover': { color: '#fff' } }}
|
||||
>
|
||||
{copiedId === asset.id ? <CheckCircle2 size={13} /> : <Copy size={13} />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="td" sx={{ p: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
px: 1,
|
||||
py: 0.3,
|
||||
borderRadius: '6px',
|
||||
fontSize: '10px',
|
||||
fontFamily: FONT_MONO,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
bgcolor: asset.status === 'active' ? 'rgba(34, 197, 94, 0.15)' : 'rgba(245, 158, 11, 0.15)',
|
||||
color: asset.status === 'active' ? '#34d399' : '#fbbf24',
|
||||
border: `1px solid ${asset.status === 'active' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(245, 158, 11, 0.3)'}`,
|
||||
}}
|
||||
<Box component="td">
|
||||
<Typography
|
||||
component="a"
|
||||
href={asset.browserDownloadUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.cyanLight, textDecoration: 'none', '&:hover': { color: C.bright } }}
|
||||
>
|
||||
{asset.status}
|
||||
</Box>
|
||||
Download ↗
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DoubleBezelCard>
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseHistoryPanel({ hub }: { hub: ReleaseHub }): React.ReactElement {
|
||||
return (
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright, mb: 2 }}>
|
||||
Release Channel History
|
||||
</Typography>
|
||||
{hub.releases.length === 0 ? (
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>발행된 릴리스가 없습니다.</Typography>
|
||||
) : (
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Box component="table" sx={tableSx}>
|
||||
<Box component="thead">
|
||||
<Box component="tr">
|
||||
<Box component="th">Tag</Box>
|
||||
<Box component="th">Release</Box>
|
||||
<Box component="th">Published</Box>
|
||||
<Box component="th">Channel</Box>
|
||||
<Box component="th">Assets</Box>
|
||||
<Box component="th">Downloads</Box>
|
||||
<Box component="th">Source</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{hub.releases.map((release) => (
|
||||
<Box component="tr" key={release.id}>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
|
||||
{release.tagName}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.bright }}>
|
||||
{release.name}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>{formatDate(release.publishedAt)}</Box>
|
||||
<Box component="td">
|
||||
<Box sx={statusBadgeSx(release.isPrerelease ? 'orange' : 'green')}>
|
||||
{release.isPrerelease ? 'PRE-RELEASE' : 'STABLE'}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
|
||||
{release.assets.length.toLocaleString()}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
|
||||
{release.downloadCount.toLocaleString()}
|
||||
</Box>
|
||||
<Box component="td">
|
||||
<Typography
|
||||
component="a"
|
||||
href={release.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.cyanLight, textDecoration: 'none', '&:hover': { color: C.bright } }}
|
||||
>
|
||||
Forgejo ↗
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DoubleBezelCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function ReleasesManagementPage(): Promise<React.ReactElement> {
|
||||
await requireManager()
|
||||
|
||||
let hub: ReleaseHub | null = null
|
||||
let feedError: string | null = null
|
||||
try {
|
||||
hub = await fetchReleaseHub()
|
||||
} catch (error) {
|
||||
feedError = error instanceof Error ? error.message : 'Unknown release feed error'
|
||||
}
|
||||
|
||||
if (!hub) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<HeaderBar feedLive={false} repoHtmlUrl="https://git.chanpaca.net/yunchan/d3ro-voice" />
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '14px', fontWeight: 600, color: C.orange400, mb: 1 }}>
|
||||
Forgejo 릴리스 피드에 연결하지 못했습니다.
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>{feedError}</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mt: 1.5 }}>
|
||||
네트워크 상태를 확인하거나 RELEASE_REPO_URL 환경변수를 점검해주세요. 샘플 수치는 표시하지 않습니다.
|
||||
</Typography>
|
||||
</DoubleBezelCard>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<HeaderBar feedLive repoHtmlUrl={hub.repoHtmlUrl} />
|
||||
<KpiCards hub={hub} />
|
||||
<LatestAssetsPanel latest={hub.latestStable} />
|
||||
<ReleaseHistoryPanel hub={hub} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
// 구독 수정/삭제 클라이언트 컴포넌트
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Box, Button } from '@mui/material'
|
||||
import { Alert, Box, Button } from '@mui/material'
|
||||
import { d3roFontMono } from '@d3ro/ui/theme'
|
||||
import { SubscriptionForm } from '@/components/subscription-form'
|
||||
import { MemoDialog } from '@/components/memo-dialog'
|
||||
|
|
@ -36,9 +36,11 @@ export function SubscriptionDetailClient({
|
|||
const router = useRouter()
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||
|
||||
const handleDelete = async (memo: string): Promise<void> => {
|
||||
setDeleteLoading(true)
|
||||
setDeleteError(null)
|
||||
try {
|
||||
await callAdminApi(`admin-subscriptions?userId=${userId}`, {
|
||||
method: 'DELETE',
|
||||
|
|
@ -46,8 +48,8 @@ export function SubscriptionDetailClient({
|
|||
})
|
||||
setDeleteOpen(false)
|
||||
router.refresh()
|
||||
} catch {
|
||||
// error handled in dialog
|
||||
} catch (error) {
|
||||
setDeleteError(error instanceof Error ? error.message : 'Subscription deletion failed')
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
|
|
@ -74,6 +76,7 @@ export function SubscriptionDetailClient({
|
|||
{/* admin 이상: 삭제 가능 */}
|
||||
{canCreateDelete && (
|
||||
<>
|
||||
{deleteError && <Alert severity="error" sx={{ mt: 2 }}>{deleteError}</Alert>}
|
||||
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
|
|
|||
|
|
@ -4,57 +4,57 @@
|
|||
import { Box, Typography } from '@mui/material'
|
||||
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
|
||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import { getSupabaseAdminClient, isSupabaseAdminConfigured } from '@/lib/supabase-admin'
|
||||
import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel'
|
||||
import { requireManager, hasMinRole } from '@/lib/admin-guard'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { SubscriptionDetailClient } from './client'
|
||||
import { fetchUsers } from '@/lib/api-server'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default async function SubscriptionDetailPage({ params }: PageProps): Promise<React.ReactElement> {
|
||||
if (!isSupabaseAdminConfigured()) {
|
||||
return (
|
||||
<UnavailableAdminPanel
|
||||
title="Subscription Detail"
|
||||
capability="Plans, billing, payment history"
|
||||
reason="SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 Supabase 관리 데이터에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다."
|
||||
/>
|
||||
)
|
||||
}
|
||||
const { id: userId } = await params
|
||||
const admin = await requireManager()
|
||||
const supabase = await getSupabaseServerClient()
|
||||
const supabase = await getSupabaseAdminClient()
|
||||
|
||||
const [subRes, profileRes, auditRes] = await Promise.all([
|
||||
supabase.from('subscriptions').select('*').eq('user_id', userId).maybeSingle(),
|
||||
supabase.from('subscriptions').select('id, user_id, tier, status, provider, payment_provider, current_period_start, current_period_end, overage_credits, admin_note, cancel_at, created_at, updated_at').eq('user_id', userId).maybeSingle(),
|
||||
supabase.from('profiles').select('id, name, tier, role').eq('id', userId).maybeSingle(),
|
||||
supabase.from('audit_log').select('*')
|
||||
supabase.from('audit_log').select('id, action, target_type, target_id, memo, created_at')
|
||||
.eq('target_id', userId)
|
||||
.eq('target_type', 'subscription')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20),
|
||||
])
|
||||
|
||||
// Fallback to mock user
|
||||
const allUsers = await fetchUsers()
|
||||
const matchedUser = allUsers.find((u) => String(u.id) === userId || u.uid === userId) || allUsers[0]
|
||||
if (subRes.error) throw new Error(`Supabase subscription failed: ${subRes.error.message}`)
|
||||
if (profileRes.error) throw new Error(`Supabase profile failed: ${profileRes.error.message}`)
|
||||
if (auditRes.error) throw new Error(`Supabase audit log failed: ${auditRes.error.message}`)
|
||||
if (!profileRes.data) notFound()
|
||||
|
||||
const profile = profileRes.data || {
|
||||
id: matchedUser.uid,
|
||||
name: matchedUser.name,
|
||||
tier: matchedUser.tier,
|
||||
role: matchedUser.role,
|
||||
const profile = profileRes.data
|
||||
const sub = subRes.data
|
||||
const auditLogs = auditRes.data ?? []
|
||||
|
||||
if (sub && (!['free', 'pro', 'pro_plus'].includes(sub.tier) ||
|
||||
!['active', 'canceled', 'past_due', 'expired'].includes(sub.status) ||
|
||||
!Number.isInteger(sub.overage_credits))) {
|
||||
throw new Error('Supabase subscription response is invalid')
|
||||
}
|
||||
|
||||
const sub = subRes.data || {
|
||||
tier: matchedUser.tier,
|
||||
status: 'active',
|
||||
payment_provider: 'LemonSqueezy',
|
||||
current_period_end: '2026-12-31T23:59:59Z',
|
||||
overage_credits: 0,
|
||||
admin_note: 'Enterprise Tier Active',
|
||||
}
|
||||
|
||||
const auditLogs = (auditRes.data && auditRes.data.length > 0) ? auditRes.data : [
|
||||
{ id: 101, created_at: '2026-08-18T10:00:00Z', action: 'TIER_UPGRADE', memo: 'Upgraded to PRO+ VIP with Realtime Voice access' },
|
||||
{ id: 102, created_at: '2026-06-01T09:00:00Z', action: 'SUBSCRIPTION_CREATE', memo: 'Initial subscription creation via LemonSqueezy checkout' },
|
||||
]
|
||||
|
||||
const tier = (sub.tier as string) || (profile.tier as string) || 'free'
|
||||
const tier = (sub?.tier as string | undefined) || (profile.tier as string) || 'free'
|
||||
const isProPlus = tier === 'pro_plus'
|
||||
const isPro = tier === 'pro'
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
|
|||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
color: C.bright,
|
||||
letterSpacing: '-0.02em',
|
||||
m: 0,
|
||||
|
|
@ -119,7 +119,7 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
|
|||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1fr 1fr' }, gap: 3 }}>
|
||||
{/* User Info Card */}
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright, mb: 2 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright, mb: 2 }}>
|
||||
Account Identifiers
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, fontFamily: FONT_SANS, fontSize: '13px' }}>
|
||||
|
|
@ -131,15 +131,15 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
|
|||
|
||||
{/* Subscription State Card */}
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright, mb: 2 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright, mb: 2 }}>
|
||||
Contract Status & Pricing
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, fontFamily: FONT_SANS, fontSize: '13px' }}>
|
||||
<Row label="Plan Level" value={isProPlus ? 'PRO+ VIP ($29/mo)' : isPro ? 'PRO ($12/mo)' : 'FREE'} />
|
||||
<Row label="Current Status" value={((sub.status as string) ?? 'active').toUpperCase()} />
|
||||
<Row label="Payment Gateway" value={((sub.payment_provider as string) ?? 'LemonSqueezy').toUpperCase()} />
|
||||
<Row label="Contract Expires / Renews" value={sub.current_period_end ? new Date(sub.current_period_end as string).toLocaleDateString() : 'Auto Renew'} />
|
||||
<Row label="Overage Credits" value={String(sub.overage_credits ?? 0)} isMono />
|
||||
<Row label="Plan Level" value={isProPlus ? 'PRO+ VIP' : isPro ? 'PRO' : 'FREE'} />
|
||||
<Row label="Current Status" value={sub?.status ? String(sub.status).toUpperCase() : 'NO SUBSCRIPTION'} />
|
||||
<Row label="Payment Gateway" value={sub?.payment_provider ? String(sub.payment_provider).toUpperCase() : 'NOT ASSIGNED'} />
|
||||
<Row label="Contract Expires / Renews" value={sub?.current_period_end ? new Date(sub.current_period_end as string).toLocaleDateString() : 'NOT SET'} />
|
||||
<Row label="Overage Credits" value={sub ? String(sub.overage_credits) : 'NO SUBSCRIPTION'} isMono />
|
||||
</Box>
|
||||
</DoubleBezelCard>
|
||||
</Box>
|
||||
|
|
@ -151,10 +151,10 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
|
|||
canEdit={true}
|
||||
canCreateDelete={hasMinRole(admin, 'admin')}
|
||||
initialSub={sub ? ({
|
||||
tier: (sub.tier as 'free' | 'pro' | 'pro_plus') ?? 'free',
|
||||
status: (sub.status as 'active' | 'canceled' | 'past_due' | 'expired') ?? 'active',
|
||||
tier: sub.tier as 'free' | 'pro' | 'pro_plus',
|
||||
status: sub.status as 'active' | 'canceled' | 'past_due' | 'expired',
|
||||
currentPeriodEnd: (sub.current_period_end as string | null) ?? null,
|
||||
overageCredits: (sub.overage_credits as number) ?? 0,
|
||||
overageCredits: sub.overage_credits as number,
|
||||
adminNote: (sub.admin_note as string | null) ?? null,
|
||||
}) : undefined}
|
||||
/>
|
||||
|
|
@ -162,7 +162,7 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
|
|||
{/* Audit Trail Table */}
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 500, color: C.bright }}>
|
||||
Subscription Security Audit Trail
|
||||
</Typography>
|
||||
<TactileBadge tone="mono" mono>
|
||||
|
|
@ -181,7 +181,13 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
|
|||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{auditLogs.map((log) => (
|
||||
{auditLogs.length === 0 ? (
|
||||
<Box component="tr">
|
||||
<Box component="td" colSpan={4} sx={{ textAlign: 'center', color: C.dim, py: 3 }}>
|
||||
No subscription audit events have been recorded.
|
||||
</Box>
|
||||
</Box>
|
||||
) : auditLogs.map((log) => (
|
||||
<Box component="tr" key={log.id as number}>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
|
||||
{new Date(log.created_at as string).toLocaleString()}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function NewSubscriptionClient({ initialUserId }: NewSubscriptionClientPr
|
|||
size="small"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
placeholder="e.g. usr_d3ro_001 or Supabase UUID..."
|
||||
placeholder="Supabase user UUID"
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
bgcolor: 'rgba(10, 17, 31, 0.7)',
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export default async function NewSubscriptionPage({ searchParams }: PageProps):
|
|||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
color: C.bright,
|
||||
letterSpacing: '-0.02em',
|
||||
m: 0,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
// apps/admin/src/app/(admin)/subscriptions/page.tsx
|
||||
// D3RO Voice — Subscriptions & ARR Revenue Console (Midnight Glass v2)
|
||||
// D3RO Voice — Subscription Operations Console
|
||||
|
||||
import { Box, Typography, Button } from '@mui/material'
|
||||
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, filterBtnSx, statusBadgeSx, primaryButtonSx } from '@/lib/console-theme'
|
||||
import { DoubleBezelCard, TactileBadge, StatRing } from '@d3ro/ui/components/ds'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import { fetchServerStats } from '@/lib/api-server'
|
||||
import { getSupabaseAdminClient, isSupabaseAdminConfigured } from '@/lib/supabase-admin'
|
||||
import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel'
|
||||
import { hasMinRole, requireManager } from '@/lib/admin-guard'
|
||||
import { LicenseIssuerButton } from '@/components/license-issuer-button'
|
||||
import Link from 'next/link'
|
||||
|
||||
|
|
@ -19,7 +20,6 @@ interface SubRow {
|
|||
cancel_at: string | null
|
||||
renewal_failures: number
|
||||
profile_name: string | null
|
||||
mrrAmount: number
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
|
|
@ -27,57 +27,68 @@ interface PageProps {
|
|||
}
|
||||
|
||||
export default async function AdminSubscriptionsPage({ searchParams }: PageProps): Promise<React.ReactElement> {
|
||||
if (!isSupabaseAdminConfigured()) {
|
||||
return (
|
||||
<UnavailableAdminPanel
|
||||
title="Subscription Management"
|
||||
capability="Plans, billing, payment history"
|
||||
reason="SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 Supabase 관리 데이터에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다."
|
||||
/>
|
||||
)
|
||||
}
|
||||
const params = await searchParams
|
||||
const statusFilter = params.status ?? 'all'
|
||||
const supabase = await getSupabaseServerClient()
|
||||
const serverStats = await fetchServerStats()
|
||||
if (!['all', 'active', 'canceled', 'past_due', 'expired'].includes(statusFilter)) {
|
||||
throw new Error('Invalid subscription status filter')
|
||||
}
|
||||
const admin = await requireManager()
|
||||
const supabase = await getSupabaseAdminClient()
|
||||
|
||||
const subQuery = supabase
|
||||
.from('subscriptions')
|
||||
.select('id, user_id, tier, status, payment_provider, current_period_end, cancel_at')
|
||||
.order('current_period_end', { ascending: true })
|
||||
.limit(100)
|
||||
|
||||
if (statusFilter !== 'all') subQuery.eq('status', statusFilter)
|
||||
|
||||
const { data: rawSubs } = await subQuery
|
||||
const rawSubsArr = (rawSubs ?? []) as Array<Record<string, unknown>>
|
||||
const rawSubsArr: Array<Record<string, unknown>> = []
|
||||
for (let page = 0; page < 100; page += 1) {
|
||||
let query = supabase
|
||||
.from('subscriptions')
|
||||
.select('id, user_id, tier, status, payment_provider, current_period_end, cancel_at, renewal_failures')
|
||||
.order('current_period_end', { ascending: true })
|
||||
.range(page * 500, page * 500 + 499)
|
||||
if (statusFilter !== 'all') query = query.eq('status', statusFilter)
|
||||
const { data, error } = await query
|
||||
if (error) throw new Error('Supabase subscriptions failed')
|
||||
rawSubsArr.push(...((data ?? []) as Array<Record<string, unknown>>))
|
||||
if ((data?.length ?? 0) < 500) break
|
||||
if (page === 99) throw new Error('Supabase subscription directory exceeds the supported administrative window')
|
||||
}
|
||||
|
||||
let subs: SubRow[] = []
|
||||
|
||||
if (rawSubsArr.length > 0) {
|
||||
const userIds = rawSubsArr.map((s) => s.user_id as string)
|
||||
const { data: rawProfiles } = userIds.length > 0
|
||||
? await supabase.from('profiles').select('id, name').in('id', userIds)
|
||||
: { data: [] }
|
||||
const profileChunks = await Promise.all(Array.from({ length: Math.ceil(userIds.length / 200) }, (_, index) =>
|
||||
supabase.from('profiles').select('id, name').in('id', userIds.slice(index * 200, index * 200 + 200))))
|
||||
if (profileChunks.some((result) => result.error)) throw new Error('Supabase subscription profiles failed')
|
||||
const rawProfiles = profileChunks.flatMap((result) => result.data ?? [])
|
||||
const profileMap = new Map(
|
||||
((rawProfiles ?? []) as Array<Record<string, unknown>>).map((p) => [p.id as string, (p.name as string) ?? null])
|
||||
)
|
||||
|
||||
subs = rawSubsArr.map((row) => ({
|
||||
id: row.id as string,
|
||||
user_id: row.user_id as string,
|
||||
tier: (row.tier as string) ?? 'free',
|
||||
status: (row.status as string) ?? 'active',
|
||||
payment_provider: (row.payment_provider as string) ?? 'Stripe',
|
||||
current_period_end: row.current_period_end as string | null,
|
||||
cancel_at: row.cancel_at as string | null,
|
||||
renewal_failures: (row.renewal_failures as number | undefined) ?? 0,
|
||||
profile_name: profileMap.get(row.user_id as string) ?? null,
|
||||
mrrAmount: row.tier === 'enterprise' ? 120 : row.tier === 'team' ? 25 : row.tier === 'pro_plus' ? 19.9 : row.tier === 'pro' ? 9.9 : 0,
|
||||
}))
|
||||
} else {
|
||||
// Rich Mock Fallback Subscriptions
|
||||
subs = [
|
||||
{ id: 'sub_001', user_id: 'usr_d3ro_001', tier: 'enterprise', status: 'active', payment_provider: 'Stripe', current_period_end: '2027-01-15T00:00:00Z', cancel_at: null, renewal_failures: 0, profile_name: 'D3RO System Architect', mrrAmount: 120 },
|
||||
{ id: 'sub_002', user_id: 'usr_d3ro_002', tier: 'team', status: 'active', payment_provider: 'Toss Payments', current_period_end: '2026-11-10T00:00:00Z', cancel_at: null, renewal_failures: 0, profile_name: 'Sarah Kim (Design Team)', mrrAmount: 75 },
|
||||
{ id: 'sub_003', user_id: 'usr_d3ro_003', tier: 'pro_plus', status: 'active', payment_provider: 'Stripe', current_period_end: '2026-10-02T00:00:00Z', cancel_at: null, renewal_failures: 0, profile_name: 'Minho Park', mrrAmount: 19.9 },
|
||||
{ id: 'sub_004', user_id: 'usr_d3ro_004', tier: 'pro', status: 'active', payment_provider: 'Payple', current_period_end: '2026-09-18T00:00:00Z', cancel_at: null, renewal_failures: 0, profile_name: 'Alex Chen', mrrAmount: 9.9 },
|
||||
{ id: 'sub_005', user_id: 'usr_d3ro_005', tier: 'pro', status: 'active', payment_provider: 'Toss Payments', current_period_end: '2026-09-01T00:00:00Z', cancel_at: null, renewal_failures: 0, profile_name: 'Jisoo Lee', mrrAmount: 9.9 },
|
||||
{ id: 'sub_006', user_id: 'usr_d3ro_006', tier: 'pro_plus', status: 'active', payment_provider: 'Stripe', current_period_end: '2026-12-20T00:00:00Z', cancel_at: null, renewal_failures: 0, profile_name: 'David Wilson', mrrAmount: 19.9 },
|
||||
{ id: 'sub_007', user_id: 'usr_d3ro_007', tier: 'free', status: 'active', payment_provider: 'None', current_period_end: null, cancel_at: null, renewal_failures: 0, profile_name: 'Hyunjin Choi', mrrAmount: 0 },
|
||||
{ id: 'sub_008', user_id: 'usr_d3ro_008', tier: 'free', status: 'active', payment_provider: 'None', current_period_end: null, cancel_at: null, renewal_failures: 0, profile_name: 'Elena Rostova', mrrAmount: 0 },
|
||||
]
|
||||
subs = rawSubsArr.map((row) => {
|
||||
if (typeof row.id !== 'string' || typeof row.user_id !== 'string' ||
|
||||
typeof row.tier !== 'string' || typeof row.status !== 'string' ||
|
||||
typeof row.payment_provider !== 'string' || !Number.isInteger(row.renewal_failures)) {
|
||||
throw new Error('Supabase subscription response contains an invalid row')
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
tier: row.tier,
|
||||
status: row.status,
|
||||
payment_provider: row.payment_provider,
|
||||
current_period_end: typeof row.current_period_end === 'string' ? row.current_period_end : null,
|
||||
cancel_at: typeof row.cancel_at === 'string' ? row.cancel_at : null,
|
||||
renewal_failures: row.renewal_failures as number,
|
||||
profile_name: profileMap.get(row.user_id) ?? null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const filteredSubs = statusFilter === 'all' ? subs : subs.filter((s) => s.status === statusFilter)
|
||||
|
|
@ -114,16 +125,16 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
|||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
color: C.bright,
|
||||
letterSpacing: '-0.02em',
|
||||
m: 0,
|
||||
}}
|
||||
>
|
||||
Subscriptions & ARR Analytics
|
||||
Subscription Operations
|
||||
</Typography>
|
||||
<TactileBadge ledColor="purple" ledPulse tone="accent" mono>
|
||||
${serverStats.arrUsd.toLocaleString()} ARR
|
||||
{subs.length} RECORDS
|
||||
</TactileBadge>
|
||||
</Box>
|
||||
<Typography
|
||||
|
|
@ -135,7 +146,7 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
|||
mt: 0.25,
|
||||
}}
|
||||
>
|
||||
LEMONSQUEEZY SYNC • 3-TIER MONETIZATION • AUTO RENEWAL
|
||||
Supabase subscriptions, payment providers, renewal state
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -149,12 +160,14 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
|||
</Link>
|
||||
))}
|
||||
<Box sx={{ height: 20, width: '1px', bgcolor: C.borderHl, mx: 0.5 }} />
|
||||
<LicenseIssuerButton />
|
||||
<Link href="/subscriptions/new" style={{ textDecoration: 'none' }}>
|
||||
<Button variant="contained" size="small" sx={primaryButtonSx}>
|
||||
+ Create Subscription
|
||||
</Button>
|
||||
</Link>
|
||||
{hasMinRole(admin, 'super_admin') && <LicenseIssuerButton />}
|
||||
{hasMinRole(admin, 'admin') && (
|
||||
<Link href="/subscriptions/new" style={{ textDecoration: 'none' }}>
|
||||
<Button variant="contained" size="small" sx={primaryButtonSx}>
|
||||
+ Create Subscription
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
|
@ -163,51 +176,51 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
|||
<DoubleBezelCard interactive bezelPadding="5px" innerPadding="20px">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', fontWeight: 600, color: C.dim, textTransform: 'uppercase' }}>
|
||||
Monthly Recurring (MRR)
|
||||
Total Subscription Records
|
||||
</Typography>
|
||||
<StatRing color="purple" size={38}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', fontWeight: 700 }}>$</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', fontWeight: 500 }}>#</Typography>
|
||||
</StatRing>
|
||||
</Box>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '24px', fontWeight: 700, color: C.bright }}>
|
||||
${serverStats.mrrUsd.toLocaleString()}
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '24px', fontWeight: 500, color: C.bright }}>
|
||||
{subs.length.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.green400, mt: 0.5 }}>
|
||||
↑ 18.4% vs last month
|
||||
Persisted in Supabase
|
||||
</Typography>
|
||||
</DoubleBezelCard>
|
||||
|
||||
<DoubleBezelCard interactive bezelPadding="5px" innerPadding="20px">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', fontWeight: 600, color: C.dim, textTransform: 'uppercase' }}>
|
||||
Paid Subscribers (Pro/Pro+)
|
||||
Active Subscriptions
|
||||
</Typography>
|
||||
<StatRing color="blue" size={38}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', fontWeight: 700 }}>VIP</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', fontWeight: 500 }}>ON</Typography>
|
||||
</StatRing>
|
||||
</Box>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '24px', fontWeight: 700, color: C.bright }}>
|
||||
{(serverStats.tierDistribution.pro + serverStats.tierDistribution.pro_plus).toLocaleString()} Paid
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '24px', fontWeight: 500, color: C.bright }}>
|
||||
{subs.filter((subscription) => subscription.status === 'active').length.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.cyanLight, mt: 0.5 }}>
|
||||
{serverStats.tierDistribution.pro_plus} Pro+ • {serverStats.tierDistribution.pro} Pro
|
||||
Current status = active
|
||||
</Typography>
|
||||
</DoubleBezelCard>
|
||||
|
||||
<DoubleBezelCard interactive bezelPadding="5px" innerPadding="20px">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', fontWeight: 600, color: C.dim, textTransform: 'uppercase' }}>
|
||||
Renewal Success Rate
|
||||
Renewal Failures
|
||||
</Typography>
|
||||
<StatRing color="green" size={38}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', fontWeight: 700 }}>%</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', fontWeight: 500 }}>%</Typography>
|
||||
</StatRing>
|
||||
</Box>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '24px', fontWeight: 700, color: C.green400 }}>
|
||||
99.4%
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '24px', fontWeight: 500, color: C.green400 }}>
|
||||
{subs.reduce((total, subscription) => total + subscription.renewal_failures, 0).toLocaleString()}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
|
||||
0.6% Churn • Fast Retry System
|
||||
Persisted retry failures
|
||||
</Typography>
|
||||
</DoubleBezelCard>
|
||||
</Box>
|
||||
|
|
@ -215,8 +228,8 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
|||
{/* Subscription Table */}
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
Active Subscription Contracts
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
|
||||
Subscription Contracts
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
SHOWING {filteredSubs.length} RECORDS
|
||||
|
|
@ -230,7 +243,7 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
|||
<Box component="th">SUBSCRIBER</Box>
|
||||
<Box component="th">TIER</Box>
|
||||
<Box component="th">STATUS</Box>
|
||||
<Box component="th">MRR VALUE</Box>
|
||||
<Box component="th">RENEWAL FAILURES</Box>
|
||||
<Box component="th">PROVIDER</Box>
|
||||
<Box component="th">EXPIRES / RENEWS</Box>
|
||||
<Box component="th" sx={{ textAlign: 'right' }}>ACTION</Box>
|
||||
|
|
@ -269,14 +282,14 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
|||
{s.status.toUpperCase()}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '13px', fontWeight: 700, color: C.bright }}>
|
||||
${s.mrrAmount}/mo
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '13px', fontWeight: 500, color: C.bright }}>
|
||||
{s.renewal_failures}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
{s.payment_provider}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.text }}>
|
||||
{s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : 'Free Tier'}
|
||||
{s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : 'Not set'}
|
||||
</Box>
|
||||
<Box component="td" sx={{ textAlign: 'right' }}>
|
||||
<Link href={`/subscriptions/${s.user_id}`} style={{ textDecoration: 'none' }}>
|
||||
|
|
|
|||
|
|
@ -1,315 +1,11 @@
|
|||
// apps/admin/src/app/(admin)/support/page.tsx
|
||||
// D3RO Voice — Customer Support (CA/CS) & Diagnostics Console (Midnight Glass v2)
|
||||
|
||||
import { Box, Typography, Button } from '@mui/material'
|
||||
import {
|
||||
C,
|
||||
FONT_SANS,
|
||||
FONT_MONO,
|
||||
panelSx,
|
||||
tableSx,
|
||||
statusBadgeSx,
|
||||
primaryButtonSx,
|
||||
} from '@/lib/console-theme'
|
||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||
|
||||
interface TicketRow {
|
||||
id: string
|
||||
customerEmail: string
|
||||
category: string
|
||||
priority: 'urgent' | 'high' | 'normal'
|
||||
status: 'open' | 'in_progress' | 'resolved'
|
||||
subject: string
|
||||
createdAt: string
|
||||
slaRemaining: string
|
||||
machineId: string
|
||||
gpuAccelerated: boolean
|
||||
audioDevice: string
|
||||
aiSuggestedFix: string
|
||||
}
|
||||
|
||||
export default async function AdminSupportPage(): Promise<React.ReactElement> {
|
||||
const tickets: TicketRow[] = [
|
||||
{
|
||||
id: 'TCK-9401',
|
||||
customerEmail: 'minho.park@linecorp.com',
|
||||
category: 'CUDA GPU / VRAM',
|
||||
priority: 'urgent',
|
||||
status: 'open',
|
||||
subject: 'CUDA out of memory error during 2-hour long meeting mode transcription',
|
||||
createdAt: '12m ago',
|
||||
slaRemaining: '18m left',
|
||||
machineId: 'win-rtx3080-99af',
|
||||
gpuAccelerated: true,
|
||||
audioDevice: 'Yamaha AG03 USB Audio',
|
||||
aiSuggestedFix: 'Recommend switching model to large-v3-turbo with int8 quantization and enabling 10-minute auto-chunking.',
|
||||
},
|
||||
{
|
||||
id: 'TCK-9402',
|
||||
customerEmail: 'tax_admin@krafton.com',
|
||||
category: 'Billing / Tax Invoice',
|
||||
priority: 'normal',
|
||||
status: 'open',
|
||||
subject: '법인 정기구독 전자세금계산서 사업자등록번호 변경 요청',
|
||||
createdAt: '45m ago',
|
||||
slaRemaining: '3h 15m left',
|
||||
machineId: 'mac-m3max-01bc',
|
||||
gpuAccelerated: false,
|
||||
audioDevice: 'Built-in Microphone',
|
||||
aiSuggestedFix: 'Verify business registration certificate on NTS HomeTax and re-issue Toss Payments tax invoice automatically.',
|
||||
},
|
||||
{
|
||||
id: 'TCK-9403',
|
||||
customerEmail: 'sarah.k@designstudio.io',
|
||||
category: 'Audio Hardware / STT',
|
||||
priority: 'high',
|
||||
status: 'in_progress',
|
||||
subject: 'Microphone permission denied after Windows 11 24H2 update',
|
||||
createdAt: '1h 10m ago',
|
||||
slaRemaining: '45m left',
|
||||
machineId: 'win-thinkpad-44a1',
|
||||
gpuAccelerated: false,
|
||||
audioDevice: 'Realtek High Definition Audio',
|
||||
aiSuggestedFix: 'Guide user to Windows Settings > Privacy & Security > Microphone > Let desktop apps access your microphone.',
|
||||
},
|
||||
{
|
||||
id: 'TCK-9404',
|
||||
customerEmail: 'alex.chen@cursor.sh',
|
||||
category: 'Feature / Prompt',
|
||||
priority: 'normal',
|
||||
status: 'resolved',
|
||||
subject: 'Request custom dictionary sync via CLI webhook',
|
||||
createdAt: '5h ago',
|
||||
slaRemaining: 'Met SLA (24m)',
|
||||
machineId: 'linux-popos-77e2',
|
||||
gpuAccelerated: true,
|
||||
audioDevice: 'Shure SM7B + Scarlett Solo',
|
||||
aiSuggestedFix: 'Provided OpenAPI documentation for /api/dictionary/sync and token authentication header guide.',
|
||||
},
|
||||
]
|
||||
import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel'
|
||||
|
||||
export default function AdminSupportPage(): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
...panelSx,
|
||||
minHeight: 84,
|
||||
px: { xs: 2.5, md: 4 },
|
||||
py: 2,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 0.5 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: { xs: '20px', md: '24px' },
|
||||
fontWeight: 800,
|
||||
color: C.bright,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
Customer Support (CA/CS) & Diagnostics Desk
|
||||
</Typography>
|
||||
<TactileBadge mono tone="warning">
|
||||
4 OPEN TICKETS
|
||||
</TactileBadge>
|
||||
</Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>
|
||||
AI-first customer triage, live hardware telemetry inspector, automated 7-day refund verification.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Button
|
||||
sx={{
|
||||
...primaryButtonSx,
|
||||
bgcolor: 'rgba(139, 92, 246, 0.15)',
|
||||
color: C.purple400,
|
||||
border: `1px solid ${C.borderHl}`,
|
||||
'&:hover': { bgcolor: 'rgba(139, 92, 246, 0.25)' },
|
||||
}}
|
||||
>
|
||||
Sync Channel.io / Zendesk
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Support KPI Metrics */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<DoubleBezelCard interactive>
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
|
||||
Pending Tickets
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.orange400, my: 0.5 }}>
|
||||
4 <span style={{ fontSize: '14px', color: C.dim, fontWeight: 500 }}>/ 180 total</span>
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.orange400 }}>
|
||||
1 Urgent Ticket
|
||||
</Typography>
|
||||
</Box>
|
||||
</DoubleBezelCard>
|
||||
|
||||
<DoubleBezelCard interactive>
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
|
||||
First Response Time
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.cyanLight, my: 0.5 }}>
|
||||
4.2 min
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.green400 }}>
|
||||
99.4% SLA Compliance
|
||||
</Typography>
|
||||
</Box>
|
||||
</DoubleBezelCard>
|
||||
|
||||
<DoubleBezelCard interactive>
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
|
||||
AI Auto-Resolution Rate
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.bright, my: 0.5 }}>
|
||||
78.5%
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.green400 }}>
|
||||
141 resolved by AI Bot
|
||||
</Typography>
|
||||
</Box>
|
||||
</DoubleBezelCard>
|
||||
|
||||
<DoubleBezelCard interactive>
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
|
||||
CSAT Satisfaction Score
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.green400, my: 0.5 }}>
|
||||
4.92 <span style={{ fontSize: '14px', color: C.dim, fontWeight: 500 }}>/ 5.0</span>
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
Based on 92 ratings
|
||||
</Typography>
|
||||
</Box>
|
||||
</DoubleBezelCard>
|
||||
</Box>
|
||||
|
||||
{/* Tickets Queue Table */}
|
||||
<Box sx={{ ...panelSx, p: { xs: 2, md: 3 } }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
Incoming Ticket Queue & Diagnostic Payloads
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
|
||||
Real-time Channel.io Webhook Active
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Box component="table" sx={tableSx}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticket ID</th>
|
||||
<th>Customer / User</th>
|
||||
<th>Category</th>
|
||||
<th>Subject & Issue</th>
|
||||
<th>Priority</th>
|
||||
<th>SLA Timer</th>
|
||||
<th>Hardware / Telemetry</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tickets.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', fontWeight: 700, color: C.cyanLight }}>
|
||||
{t.id}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>
|
||||
{t.createdAt}
|
||||
</Typography>
|
||||
</td>
|
||||
<td>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', fontWeight: 600, color: C.bright }}>
|
||||
{t.customerEmail}
|
||||
</Typography>
|
||||
</td>
|
||||
<td>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
{t.category}
|
||||
</Typography>
|
||||
</td>
|
||||
<td style={{ maxWidth: 320 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.bright, fontWeight: 500 }} noWrap>
|
||||
{t.subject}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.cyanLight }} noWrap>
|
||||
💡 AI: {t.aiSuggestedFix}
|
||||
</Typography>
|
||||
</td>
|
||||
<td>
|
||||
<Box
|
||||
sx={{
|
||||
...statusBadgeSx,
|
||||
bgcolor: t.priority === 'urgent' ? 'rgba(239, 68, 68, 0.15)' : t.priority === 'high' ? 'rgba(245, 158, 11, 0.15)' : 'rgba(59, 130, 246, 0.15)',
|
||||
color: t.priority === 'urgent' ? C.red400 : t.priority === 'high' ? C.orange400 : C.cyanLight,
|
||||
border: `1px solid ${t.priority === 'urgent' ? 'rgba(239, 68, 68, 0.3)' : t.priority === 'high' ? 'rgba(245, 158, 11, 0.3)' : 'rgba(59, 130, 246, 0.3)'}`,
|
||||
}}
|
||||
>
|
||||
{t.priority.toUpperCase()}
|
||||
</Box>
|
||||
</td>
|
||||
<td>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: t.priority === 'urgent' ? C.red400 : C.green400,
|
||||
}}
|
||||
>
|
||||
{t.slaRemaining}
|
||||
</Typography>
|
||||
</td>
|
||||
<td>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.bright }}>
|
||||
{t.audioDevice}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '9px', color: C.dim }}>
|
||||
{t.machineId} • GPU: {t.gpuAccelerated ? 'ON' : 'OFF'}
|
||||
</Typography>
|
||||
</td>
|
||||
<td>
|
||||
<Box
|
||||
sx={{
|
||||
...statusBadgeSx,
|
||||
bgcolor: t.status === 'open' ? 'rgba(245, 158, 11, 0.15)' : t.status === 'in_progress' ? 'rgba(59, 130, 246, 0.15)' : 'rgba(16, 185, 129, 0.15)',
|
||||
color: t.status === 'open' ? C.orange400 : t.status === 'in_progress' ? C.cyanLight : C.green400,
|
||||
border: `1px solid ${t.status === 'open' ? 'rgba(245, 158, 11, 0.3)' : t.status === 'in_progress' ? 'rgba(59, 130, 246, 0.3)' : 'rgba(16, 185, 129, 0.3)'}`,
|
||||
}}
|
||||
>
|
||||
{t.status.toUpperCase()}
|
||||
</Box>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
<UnavailableAdminPanel
|
||||
title="Customer Support Desk"
|
||||
capability="Tickets, SLA, diagnostics, refunds"
|
||||
reason="지원 티켓 공급자와 진단 수집 시스템의 인증된 서버 계약이 구성되지 않았습니다. 고객·장치·SLA 정보를 추정하거나 가짜 티켓으로 대체하지 않습니다."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
|
|||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
color: C.bright,
|
||||
letterSpacing: '-0.02em',
|
||||
m: 0,
|
||||
|
|
@ -115,7 +115,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
|
|||
mt: 0.25,
|
||||
}}
|
||||
>
|
||||
REALTIME STT AUDIO MINUTES • LLM TOKEN COUNTER • CLOUD PROVIDER ATTRIBUTION
|
||||
Realtime STT audio minutes, LLM token counter, cloud provider attribution
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -141,7 +141,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
|
|||
{m.icon}
|
||||
</StatRing>
|
||||
</Box>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '26px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '26px', fontWeight: 500, color: C.bright }}>
|
||||
{m.value}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.text, mt: 0.5 }}>
|
||||
|
|
@ -154,7 +154,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
|
|||
{/* STT Provider Usage Breakdown Table */}
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
|
||||
🎙️ Speech-to-Text (STT) Transcription Metrics by Provider
|
||||
</Typography>
|
||||
<TactileBadge tone="accent" mono>
|
||||
|
|
@ -194,7 +194,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
|
|||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
|
||||
{p.avgLatencyMs}ms
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '13px', color: C.green400, fontWeight: 700, textAlign: 'right' }}>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '13px', color: C.green400, fontWeight: 500, textAlign: 'right' }}>
|
||||
${p.totalCost.toFixed(4)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -206,7 +206,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
|
|||
|
||||
{/* Feature Token Distribution Visual Progress Bars */}
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright, mb: 2.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright, mb: 2.5 }}>
|
||||
Token & Compute Distribution by Feature
|
||||
</Typography>
|
||||
|
||||
|
|
@ -240,7 +240,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
|
|||
{/* User Breakdown Table */}
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 500, color: C.bright }}>
|
||||
Cost Attribution by Top Power Users
|
||||
</Typography>
|
||||
<TactileBadge tone="mono" mono>
|
||||
|
|
@ -274,7 +274,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
|
|||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
|
||||
{u.totalTokens.toLocaleString()}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '13px', color: C.green400, fontWeight: 700, textAlign: 'right' }}>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '13px', color: C.green400, fontWeight: 500, textAlign: 'right' }}>
|
||||
${u.totalCost.toFixed(6)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -287,7 +287,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
|
|||
{/* LLM Model Breakdown Table */}
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 500, color: C.bright }}>
|
||||
🧠 Cost & Token Breakdown by LLM Model Engine
|
||||
</Typography>
|
||||
<TactileBadge tone="accent" mono>
|
||||
|
|
@ -321,7 +321,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
|
|||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
|
||||
{m.totalTokens.toLocaleString()}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '13px', color: C.green400, fontWeight: 700, textAlign: 'right' }}>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '13px', color: C.green400, fontWeight: 500, textAlign: 'right' }}>
|
||||
${m.totalCost.toFixed(6)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -4,63 +4,60 @@
|
|||
import { Box, Typography } from '@mui/material'
|
||||
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
|
||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import { getSupabaseAdminClient, isSupabaseAdminConfigured } from '@/lib/supabase-admin'
|
||||
import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel'
|
||||
import { requireManager, isAdmin } from '@/lib/admin-guard'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { RoleChangeButton } from './role-change-button'
|
||||
import { PaymentHistory } from '@/components/payment-history'
|
||||
import { fetchUsers } from '@/lib/api-server'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default async function AdminUserDetailPage({ params }: PageProps): Promise<React.ReactElement> {
|
||||
if (!isSupabaseAdminConfigured()) {
|
||||
return (
|
||||
<UnavailableAdminPanel
|
||||
title="User Account Detail"
|
||||
capability="Accounts, tiers, roles, usage"
|
||||
reason="SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 Supabase 관리 데이터에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다."
|
||||
/>
|
||||
)
|
||||
}
|
||||
const { id } = await params
|
||||
const admin = await requireManager()
|
||||
const supabase = await getSupabaseServerClient()
|
||||
const supabase = await getSupabaseAdminClient()
|
||||
|
||||
const [profileRes, subRes, usageRes] = await Promise.all([
|
||||
supabase.from('profiles').select('*').eq('id', id).maybeSingle(),
|
||||
supabase.from('subscriptions').select('*').eq('user_id', id).maybeSingle(),
|
||||
supabase.from('daily_usage').select('*')
|
||||
const [authRes, profileRes, subRes, usageRes] = await Promise.all([
|
||||
supabase.auth.admin.getUserById(id),
|
||||
supabase.from('profiles').select('id, name, locale, tier, role, created_at, updated_at').eq('id', id).maybeSingle(),
|
||||
supabase.from('subscriptions').select('id, user_id, tier, status, provider, payment_provider, current_period_start, current_period_end, overage_credits, admin_note, cancel_at, created_at, updated_at').eq('user_id', id).maybeSingle(),
|
||||
supabase.from('daily_usage').select('date, feature, count')
|
||||
.eq('user_id', id)
|
||||
.gte('date', new Date(Date.now() - 30 * 86400000).toISOString().split('T')[0])
|
||||
.order('date', { ascending: false }),
|
||||
])
|
||||
|
||||
// If supabase profile not found, fallback to rich mock user dataset
|
||||
const allUsers = await fetchUsers()
|
||||
const matchedMock = allUsers.find((u) => String(u.id) === id || u.uid === id) || allUsers[0]
|
||||
if (authRes.error) throw new Error(`Supabase auth user failed: ${authRes.error.message}`)
|
||||
if (profileRes.error) throw new Error(`Supabase profile failed: ${profileRes.error.message}`)
|
||||
if (subRes.error) throw new Error(`Supabase subscription failed: ${subRes.error.message}`)
|
||||
if (usageRes.error) throw new Error(`Supabase usage failed: ${usageRes.error.message}`)
|
||||
if (!authRes.data.user || !profileRes.data) notFound()
|
||||
|
||||
const profile = profileRes.data || {
|
||||
id: matchedMock.uid,
|
||||
name: matchedMock.name,
|
||||
email: matchedMock.email,
|
||||
tier: matchedMock.tier,
|
||||
role: matchedMock.role,
|
||||
locale: 'ko-KR',
|
||||
created_at: matchedMock.createdAt,
|
||||
last_login: matchedMock.lastLoginAt,
|
||||
last_device: matchedMock.lastActiveDevice,
|
||||
const authUser = authRes.data.user
|
||||
const profile = {
|
||||
...profileRes.data,
|
||||
email: authUser.email ?? null,
|
||||
last_login: authUser.last_sign_in_at ?? null,
|
||||
}
|
||||
const sub = subRes.data
|
||||
const usage = usageRes.data ?? []
|
||||
|
||||
const sub = subRes.data || {
|
||||
status: 'active',
|
||||
payment_provider: 'LemonSqueezy',
|
||||
current_period_end: '2026-12-31T23:59:59Z',
|
||||
cancel_at: null,
|
||||
}
|
||||
|
||||
const usage = (usageRes.data && usageRes.data.length > 0) ? usageRes.data : [
|
||||
{ date: '2026-08-19', feature: 'Realtime Dictation (Whisper Turbo)', count: 42 },
|
||||
{ date: '2026-08-19', feature: 'Meeting Mode + Multi-Doc', count: 4 },
|
||||
{ date: '2026-08-18', feature: 'Speaker Diarization (Pyannote)', count: 12 },
|
||||
{ date: '2026-08-18', feature: 'SQLite Vector RAG Query', count: 18 },
|
||||
{ date: '2026-08-17', feature: 'Auto Polish & Refine', count: 35 },
|
||||
]
|
||||
|
||||
const tier = (profile.tier as string) ?? 'free'
|
||||
const tier = typeof profile.tier === 'string' && ['free', 'pro', 'pro_plus'].includes(profile.tier)
|
||||
? profile.tier
|
||||
: null
|
||||
const isProPlus = tier === 'pro_plus'
|
||||
const isPro = tier === 'pro'
|
||||
const userRole = ((profile.role as string) ?? 'user') as 'user' | 'manager' | 'admin' | 'super_admin'
|
||||
|
|
@ -97,7 +94,7 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
|||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
color: C.bright,
|
||||
letterSpacing: '-0.02em',
|
||||
m: 0,
|
||||
|
|
@ -106,7 +103,7 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
|||
User Profile Console
|
||||
</Typography>
|
||||
<Box component="span" sx={statusBadgeSx(isProPlus ? 'purple' : isPro ? 'green' : 'blue')}>
|
||||
{isProPlus ? 'PRO+ VIP' : tier.toUpperCase()}
|
||||
{isProPlus ? 'PRO+ VIP' : tier ? tier.toUpperCase() : 'TIER UNAVAILABLE'}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 0.25 }}>
|
||||
|
|
@ -147,19 +144,19 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
|||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#ffffff',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
fontSize: '20px',
|
||||
boxShadow: '0 0 20px rgba(59, 130, 246, 0.4)',
|
||||
}}
|
||||
>
|
||||
{((profile.name as string) || 'U').charAt(0)}
|
||||
{((profile.name as string) || (profile.email as string) || '?').charAt(0).toUpperCase()}
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '18px', fontWeight: 700, color: C.bright }}>
|
||||
{(profile.name as string) || 'D3RO User'}
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '18px', fontWeight: 500, color: C.bright }}>
|
||||
{(profile.name as string) || 'Name not provided'}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
|
||||
{(profile.email as string) || 'user@d3ro.voice'}
|
||||
{(profile.email as string) || 'Email unavailable'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -167,51 +164,60 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
|||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, fontFamily: FONT_SANS, fontSize: '13px' }}>
|
||||
<Row label="Account UID" value={(profile.id as string) || id} isMono />
|
||||
<Row label="Assigned Role" value={userRole.toUpperCase()} valueColor={userRole === 'super_admin' ? C.purple400 : C.cyanLight} isMono />
|
||||
<Row label="Language Locale" value={(profile.locale as string) || 'ko-KR'} isMono />
|
||||
<Row label="Active Platform" value={(profile.last_device as string) || 'Windows 11 x64 (Build 26100)'} />
|
||||
<Row label="Language Locale" value={(profile.locale as string) || 'Not reported'} isMono />
|
||||
<Row label="Created Date" value={new Date(profile.created_at as string).toLocaleDateString()} />
|
||||
<Row label="Last Active Session" value={profile.last_login ? new Date(profile.last_login as string).toLocaleString() : 'Recent'} />
|
||||
<Row label="Last Active Session" value={profile.last_login ? new Date(profile.last_login as string).toLocaleString() : 'Never signed in'} />
|
||||
</Box>
|
||||
</DoubleBezelCard>
|
||||
|
||||
{/* Subscription & Quota Card */}
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
|
||||
Subscription & Quota Entitlements
|
||||
</Typography>
|
||||
<Box component="span" sx={statusBadgeSx((sub.status as string) === 'active' ? 'green' : 'red')}>
|
||||
{((sub.status as string) || 'ACTIVE').toUpperCase()}
|
||||
<Box component="span" sx={statusBadgeSx((sub?.status as string) === 'active' ? 'green' : 'red')}>
|
||||
{sub?.status ? String(sub.status).toUpperCase() : 'NO SUBSCRIPTION'}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, mb: 3 }}>
|
||||
<Row label="Plan Tier" value={isProPlus ? 'PRO+ VIP ($29/mo)' : isPro ? 'PRO ($12/mo)' : 'FREE TIER'} />
|
||||
<Row label="Billing Provider" value={(sub.payment_provider as string) || 'LemonSqueezy'} />
|
||||
<Row label="Current Period End" value={sub.current_period_end ? new Date(sub.current_period_end as string).toLocaleDateString() : 'Auto Renew'} />
|
||||
<Row label="Cancel Scheduled" value={sub.cancel_at ? new Date(sub.cancel_at as string).toLocaleDateString() : 'None (Active)'} />
|
||||
<Row label="Plan Tier" value={isProPlus ? 'PRO+ VIP' : isPro ? 'PRO' : tier === 'free' ? 'FREE' : 'Unavailable'} />
|
||||
<Row label="Billing Provider" value={sub?.payment_provider ? String(sub.payment_provider) : 'Not assigned'} />
|
||||
<Row label="Current Period End" value={sub?.current_period_end ? new Date(sub.current_period_end as string).toLocaleDateString() : 'Not set'} />
|
||||
<Row label="Cancel Scheduled" value={sub?.cancel_at ? new Date(sub.cancel_at as string).toLocaleDateString() : 'Not scheduled'} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2, borderRadius: '12px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', fontWeight: 600, color: C.bright, mb: 1 }}>
|
||||
Phase 1~15.5 Enabled Features
|
||||
Entitlement source
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
<TactileBadge tone="success" mono>Whisper Turbo STT (Unlimited)</TactileBadge>
|
||||
<TactileBadge tone="accent" mono>Ollama NDJSON Stream</TactileBadge>
|
||||
{isProPlus && <TactileBadge tone="accent" mono>GPT-Realtime 2.1 Live Voice</TactileBadge>}
|
||||
<TactileBadge tone="success" mono>Meeting Summary & Multi-Doc</TactileBadge>
|
||||
<TactileBadge tone="accent" mono>SQLite Vector RAG</TactileBadge>
|
||||
{isProPlus && <TactileBadge tone="success" mono>Pyannote Diarization</TactileBadge>}
|
||||
<TactileBadge tone="mono" mono>{sub ? 'SUPABASE SUBSCRIPTION' : 'NO ACTIVE CONTRACT RECORD'}</TactileBadge>
|
||||
</Box>
|
||||
</Box>
|
||||
</DoubleBezelCard>
|
||||
</Box>
|
||||
|
||||
{/* Tier-based Enabled Features */}
|
||||
<Box sx={{ p: 2, borderRadius: '12px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', fontWeight: 600, color: C.bright, mb: 1 }}>
|
||||
Enabled Features
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
<TactileBadge tone="success" mono>Whisper Turbo STT (Unlimited)</TactileBadge>
|
||||
<TactileBadge tone="accent" mono>Ollama NDJSON Stream</TactileBadge>
|
||||
{isProPlus && <TactileBadge tone="accent" mono>GPT-Realtime 2.1 Live Voice</TactileBadge>}
|
||||
<TactileBadge tone="success" mono>Meeting Summary & Multi-Doc</TactileBadge>
|
||||
<TactileBadge tone="accent" mono>SQLite Vector RAG</TactileBadge>
|
||||
{isProPlus && <TactileBadge tone="success" mono>Pyannote Diarization</TactileBadge>}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 30-Day Activity Heatmap Table */}
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 500, color: C.bright }}>
|
||||
30-Day Feature Execution Telemetry
|
||||
</Typography>
|
||||
<TactileBadge tone="mono" mono>
|
||||
|
|
@ -230,20 +236,26 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
|||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{usage.map((row, i) => (
|
||||
<Box component="tr" key={i}>
|
||||
{usage.length === 0 ? (
|
||||
<Box component="tr">
|
||||
<Box component="td" colSpan={4} sx={{ textAlign: 'center', color: C.dim, py: 3 }}>
|
||||
No measured usage in the last 30 days.
|
||||
</Box>
|
||||
</Box>
|
||||
) : usage.map((row) => (
|
||||
<Box component="tr" key={`${row.date as string}:${row.feature as string}`}>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
|
||||
{row.date as string}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontWeight: 600, color: C.bright }}>
|
||||
{row.feature as string}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight, fontWeight: 700 }}>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight, fontWeight: 500 }}>
|
||||
{row.count as number} calls
|
||||
</Box>
|
||||
<Box component="td">
|
||||
<Box component="span" sx={statusBadgeSx('green')}>
|
||||
SUCCESS
|
||||
MEASURED
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -256,11 +268,11 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
|||
{/* Payment History */}
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 700, color: C.bright }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 500, color: C.bright }}>
|
||||
Payment History & Invoices
|
||||
</Typography>
|
||||
<TactileBadge tone="success" mono>
|
||||
LEMONSQUEEZY VERIFIED
|
||||
PAYMENT DATA
|
||||
</TactileBadge>
|
||||
</Box>
|
||||
<PaymentHistory userId={id} />
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
|
||||
import React from 'react'
|
||||
import { Box, Typography, Button } from '@mui/material'
|
||||
import { fetchUsers } from '@/lib/api-server'
|
||||
import { fetchProductUsers, isSupabaseAdminConfigured } from '@/lib/supabase-admin'
|
||||
import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel'
|
||||
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, filterBtnSx, statusBadgeSx } from '@/lib/console-theme'
|
||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||
import Link from 'next/link'
|
||||
|
|
@ -13,12 +14,25 @@ interface PageProps {
|
|||
}
|
||||
|
||||
export default async function AdminUsersPage({ searchParams }: PageProps): Promise<React.ReactElement> {
|
||||
if (!isSupabaseAdminConfigured()) {
|
||||
return (
|
||||
<UnavailableAdminPanel
|
||||
title="User Directory & CRM"
|
||||
capability="Accounts, tiers, roles, usage"
|
||||
reason="SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 Supabase 사용자 디렉터리에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다."
|
||||
/>
|
||||
)
|
||||
}
|
||||
const params = await searchParams
|
||||
const tierFilter = params.tier ?? 'all'
|
||||
const roleFilter = params.role ?? 'all'
|
||||
const searchQuery = (params.q ?? '').toLowerCase()
|
||||
if (!['all', 'free', 'pro', 'pro_plus'].includes(tierFilter)) throw new Error('Invalid user tier filter')
|
||||
if (!['all', 'user', 'manager', 'admin', 'super_admin'].includes(roleFilter)) throw new Error('Invalid user role filter')
|
||||
const rawSearchQuery = params.q ?? ''
|
||||
if (rawSearchQuery.length > 100) throw new Error('User search query is too long')
|
||||
const searchQuery = rawSearchQuery.trim().toLowerCase()
|
||||
|
||||
const allUsers = await fetchUsers()
|
||||
const allUsers = await fetchProductUsers()
|
||||
|
||||
const filteredUsers = allUsers.filter((u) => {
|
||||
if (tierFilter !== 'all' && u.tier !== tierFilter) return false
|
||||
|
|
@ -64,7 +78,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
|
|||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
color: C.bright,
|
||||
letterSpacing: '-0.02em',
|
||||
m: 0,
|
||||
|
|
@ -85,7 +99,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
|
|||
mt: 0.25,
|
||||
}}
|
||||
>
|
||||
MULTI-TIER QUOTAS • HARDWARE SESSIONS • ROLES & ACCESS CONTROL
|
||||
Supabase auth accounts, subscription tiers, roles and access control
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -105,8 +119,8 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
|
|||
{/* Main Table Card */}
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
User Account Profiles & Quota Consumption
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
|
||||
User Account Profiles
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
SHOWING {filteredUsers.length} OF {allUsers.length} USERS
|
||||
|
|
@ -120,8 +134,8 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
|
|||
<Box component="th">USER PROFILE</Box>
|
||||
<Box component="th">TIER</Box>
|
||||
<Box component="th">ROLE</Box>
|
||||
<Box component="th">DAILY QUOTA STATUS</Box>
|
||||
<Box component="th">LAST ACTIVE PLATFORM</Box>
|
||||
<Box component="th">USAGE</Box>
|
||||
<Box component="th">LAST SIGN-IN</Box>
|
||||
<Box component="th">STATUS</Box>
|
||||
<Box component="th" sx={{ textAlign: 'right' }}>ACTION</Box>
|
||||
</Box>
|
||||
|
|
@ -157,7 +171,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
|
|||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#ffffff',
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
fontSize: '13px',
|
||||
boxShadow: isProPlus ? '0 0 12px rgba(168, 85, 247, 0.4)' : 'none',
|
||||
}}
|
||||
|
|
@ -169,7 +183,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
|
|||
{u.name}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
{u.email}
|
||||
{u.email || 'Email not set'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -179,7 +193,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
|
|||
{/* Tier Badge */}
|
||||
<Box component="td">
|
||||
<Box component="span" sx={statusBadgeSx(isProPlus ? 'purple' : isPro ? 'green' : 'blue')}>
|
||||
{isProPlus ? 'PRO+ VIP' : u.tier.toUpperCase()}
|
||||
{isProPlus ? 'PRO+ VIP' : u.tier?.toUpperCase() ?? 'UNASSIGNED'}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
|
@ -192,25 +206,14 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
|
|||
|
||||
{/* Daily Quota Status */}
|
||||
<Box component="td">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', fontFamily: FONT_MONO }}>
|
||||
<span style={{ color: C.dim }}>Dictations:</span>
|
||||
<strong style={{ color: isProPlus || isPro ? C.green400 : C.bright }}>
|
||||
{u.dailyUsage.dictations} / {u.dailyUsage.dictationsMax === 9999 ? '∞' : u.dailyUsage.dictationsMax}
|
||||
</strong>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', fontFamily: FONT_MONO }}>
|
||||
<span style={{ color: C.dim }}>LLM Calls:</span>
|
||||
<strong style={{ color: C.bright }}>
|
||||
{u.dailyUsage.llmCalls} / {u.dailyUsage.llmCallsMax === 9999 ? '∞' : u.dailyUsage.llmCallsMax}
|
||||
</strong>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>
|
||||
Open user detail for measured usage
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Last Active Platform */}
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.text }}>
|
||||
{u.lastActiveDevice}
|
||||
{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : 'Never signed in'}
|
||||
</Box>
|
||||
|
||||
{/* Status */}
|
||||
|
|
|
|||
116
apps/admin/src/app/api/admin/backend/[...segments]/route.ts
Normal file
116
apps/admin/src/app/api/admin/backend/[...segments]/route.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { AdminBackendError, fetchAdminBackend } from '@/lib/backend-session'
|
||||
import type { AdminRole } from '@/lib/admin-session'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const READ_PATHS = [
|
||||
/^\/stats$/,
|
||||
/^\/users$/,
|
||||
/^\/endpoints$/,
|
||||
/^\/stt-endpoints$/,
|
||||
/^\/stt-usage$/,
|
||||
/^\/usage$/
|
||||
]
|
||||
|
||||
const MANAGER_POST_PATHS = [/^\/stt-endpoints\/\d+\/test$/]
|
||||
const ADMIN_POST_PATHS = [
|
||||
/^\/endpoints$/,
|
||||
/^\/stt-endpoints$/,
|
||||
/^\/stt-endpoints\/\d+\/set-default$/,
|
||||
/^\/stt-endpoints\/test-direct$/
|
||||
]
|
||||
const ADMIN_MUTATION_PATHS = [/^\/endpoints\/\d+$/, /^\/stt-endpoints\/\d+$/]
|
||||
const IDEMPOTENT_POST_PATHS = [/^\/endpoints$/, /^\/stt-endpoints$/, /^\/stt-endpoints\/\d+\/set-default$/]
|
||||
|
||||
function pathFor(segments: string[]): string | null {
|
||||
if (!segments.length || segments.some((segment) => !/^[A-Za-z0-9-]+$/.test(segment))) {
|
||||
return null
|
||||
}
|
||||
return `/${segments.join('/')}`
|
||||
}
|
||||
|
||||
function requiredRole(method: string, path: string): AdminRole | null {
|
||||
if (method === 'GET' && READ_PATHS.some((pattern) => pattern.test(path))) return 'manager'
|
||||
if (method === 'POST' && MANAGER_POST_PATHS.some((pattern) => pattern.test(path))) return 'manager'
|
||||
if (method === 'POST' && ADMIN_POST_PATHS.some((pattern) => pattern.test(path))) return 'admin'
|
||||
if ((method === 'PUT' || method === 'DELETE') && ADMIN_MUTATION_PATHS.some((pattern) => pattern.test(path))) {
|
||||
return 'admin'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isSameOrigin(request: NextRequest): boolean {
|
||||
const origin = request.headers.get('origin')
|
||||
if (!origin) return request.headers.get('sec-fetch-site') !== 'cross-site'
|
||||
try {
|
||||
const requestOrigin = new URL(origin).origin
|
||||
if (requestOrigin === request.nextUrl.origin) return true
|
||||
|
||||
const forwardedProto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim().toLowerCase()
|
||||
const forwardedHost = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim()
|
||||
?? request.headers.get('host')?.trim()
|
||||
if ((forwardedProto !== 'http' && forwardedProto !== 'https') ||
|
||||
!forwardedHost || !/^[A-Za-z0-9.:[\]-]+$/.test(forwardedHost)) {
|
||||
return false
|
||||
}
|
||||
return requestOrigin === `${forwardedProto}://${forwardedHost}`
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function proxy(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ segments: string[] }> }
|
||||
): Promise<NextResponse> {
|
||||
const { segments } = await context.params
|
||||
const path = pathFor(segments)
|
||||
const role = path ? requiredRole(request.method, path) : null
|
||||
if (!path || !role) return NextResponse.json({ error: 'admin_route_not_allowed' }, { status: 404 })
|
||||
if (request.method !== 'GET' && !isSameOrigin(request)) {
|
||||
return NextResponse.json({ error: 'cross_site_request_rejected' }, { status: 403 })
|
||||
}
|
||||
|
||||
let body: string | undefined
|
||||
if (request.method !== 'GET') {
|
||||
body = await request.text()
|
||||
if (new TextEncoder().encode(body).byteLength > 64 * 1024) {
|
||||
return NextResponse.json({ error: 'request_too_large' }, { status: 413 })
|
||||
}
|
||||
}
|
||||
|
||||
const mutatesState =
|
||||
(request.method === 'POST' && IDEMPOTENT_POST_PATHS.some((pattern) => pattern.test(path))) ||
|
||||
((request.method === 'PUT' || request.method === 'DELETE') && ADMIN_MUTATION_PATHS.some((pattern) => pattern.test(path)))
|
||||
const idempotencyKey = request.headers.get('idempotency-key')?.trim() ?? ''
|
||||
if (mutatesState && !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(idempotencyKey)) {
|
||||
return NextResponse.json({ error: 'valid_idempotency_key_required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const backend = await fetchAdminBackend(`${path}${request.nextUrl.search}`, {
|
||||
method: request.method,
|
||||
body,
|
||||
headers: mutatesState ? { 'Idempotency-Key': idempotencyKey } : undefined
|
||||
}, role)
|
||||
const text = await backend.text()
|
||||
return new NextResponse(text || null, {
|
||||
status: backend.status,
|
||||
headers: {
|
||||
'Content-Type': backend.headers.get('content-type') ?? 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof AdminBackendError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
return NextResponse.json({ error: 'admin_backend_unavailable' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = proxy
|
||||
export const POST = proxy
|
||||
export const PUT = proxy
|
||||
export const DELETE = proxy
|
||||
157
apps/admin/src/app/api/admin/license/route.ts
Normal file
157
apps/admin/src/app/api/admin/license/route.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
// apps/admin/src/app/api/admin/license/route.ts
|
||||
// Ed25519 라이선스 발급 — 서명 개인키는 서버 환경변수로만 공급한다.
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
issueSignedLicenseKey,
|
||||
DEFAULT_LICENSE_PRIVATE_KEY,
|
||||
type SignedLicensePayload
|
||||
} from '@d3ro/core/utils/crypto-license'
|
||||
import type { LicenseTier } from '@d3ro/core/types'
|
||||
import { AdminBackendError, fetchAdminBackend, requireVerifiedBackendSession } from '@/lib/backend-session'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const ISSUABLE_TIERS: readonly LicenseTier[] = ['pro', 'pro_plus', 'team', 'enterprise']
|
||||
const VALIDITY_MS: Record<string, number | null> = {
|
||||
'30d': 30 * 24 * 60 * 60 * 1_000,
|
||||
'365d': 365 * 24 * 60 * 60 * 1_000,
|
||||
lifetime: null
|
||||
}
|
||||
const MAX_DEVICES: Record<LicenseTier, number> = {
|
||||
free: 1,
|
||||
pro: 3,
|
||||
pro_plus: 5,
|
||||
team: 25,
|
||||
enterprise: 999
|
||||
}
|
||||
|
||||
interface SigningKey {
|
||||
privateKeyPem: string
|
||||
usedDefaultKey: boolean
|
||||
}
|
||||
|
||||
// 저장소에 포함된 기본 키쌍은 공개된 것이므로 위조 방어력이 없다. 운영 발급은
|
||||
// ADMIN_LICENSE_PRIVATE_KEY(전용 키 로테이션)로만 하고, 기본 키 사용은 명시적 opt-in.
|
||||
function resolveSigningKey(): SigningKey | null {
|
||||
const configured = process.env.ADMIN_LICENSE_PRIVATE_KEY?.trim()
|
||||
if (configured) {
|
||||
return { privateKeyPem: configured.replace(/\\n/g, '\n'), usedDefaultKey: false }
|
||||
}
|
||||
if (process.env.ADMIN_LICENSE_ALLOW_DEFAULT_KEY === 'true') {
|
||||
return { privateKeyPem: DEFAULT_LICENSE_PRIVATE_KEY, usedDefaultKey: true }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isSameOrigin(request: NextRequest): boolean {
|
||||
const origin = request.headers.get('origin')
|
||||
if (!origin) return request.headers.get('sec-fetch-site') !== 'cross-site'
|
||||
try {
|
||||
return new URL(origin).origin === request.nextUrl.origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
if (!isSameOrigin(request)) {
|
||||
return NextResponse.json({ error: 'cross_site_request_rejected' }, { status: 403 })
|
||||
}
|
||||
|
||||
try {
|
||||
await requireVerifiedBackendSession('super_admin')
|
||||
} catch (error) {
|
||||
const status = error instanceof AdminBackendError ? error.status : 401
|
||||
const message = error instanceof AdminBackendError ? error.message : 'admin_session_invalid'
|
||||
return NextResponse.json({ error: message }, { status })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid_request' }, { status: 400 })
|
||||
}
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return NextResponse.json({ error: 'invalid_request' }, { status: 400 })
|
||||
}
|
||||
|
||||
const candidate = body as Record<string, unknown>
|
||||
const customerEmail = typeof candidate.customerEmail === 'string' ? candidate.customerEmail.trim().toLowerCase() : ''
|
||||
const tier = candidate.tier
|
||||
const validity = typeof candidate.validity === 'string' ? candidate.validity : ''
|
||||
const machineId = typeof candidate.machineId === 'string' ? candidate.machineId.trim() : ''
|
||||
const teamId = typeof candidate.teamId === 'string' ? candidate.teamId.trim() : ''
|
||||
|
||||
if (!customerEmail || customerEmail.length > 150 || !customerEmail.includes('@')) {
|
||||
return NextResponse.json({ error: 'invalid_customer_email' }, { status: 400 })
|
||||
}
|
||||
if (typeof tier !== 'string' || !ISSUABLE_TIERS.includes(tier as LicenseTier)) {
|
||||
return NextResponse.json({ error: 'invalid_tier' }, { status: 400 })
|
||||
}
|
||||
if (!(validity in VALIDITY_MS)) {
|
||||
return NextResponse.json({ error: 'invalid_validity' }, { status: 400 })
|
||||
}
|
||||
if (machineId.length > 128 || teamId.length > 64) {
|
||||
return NextResponse.json({ error: 'invalid_request' }, { status: 400 })
|
||||
}
|
||||
|
||||
const signingKey = resolveSigningKey()
|
||||
if (!signingKey) {
|
||||
return NextResponse.json({ error: 'license_signing_unavailable' }, { status: 503 })
|
||||
}
|
||||
|
||||
const issuedTier = tier as LicenseTier
|
||||
const now = Date.now()
|
||||
const validityMs = VALIDITY_MS[validity]
|
||||
const payload: SignedLicensePayload = {
|
||||
licenseId: `lic-${randomUUID()}`,
|
||||
tier: issuedTier,
|
||||
customerEmail,
|
||||
issuedAt: now,
|
||||
expiresAt: validityMs === null ? null : now + validityMs,
|
||||
machineId: machineId || null,
|
||||
...(teamId ? { teamId } : {}),
|
||||
maxDevices: MAX_DEVICES[issuedTier]
|
||||
}
|
||||
|
||||
let licenseKey: string
|
||||
try {
|
||||
licenseKey = issueSignedLicenseKey(payload, signingKey.privateKeyPem)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'license_signing_failed' }, { status: 500 })
|
||||
}
|
||||
|
||||
// 발급 감사 기록(best-effort). 감사가 실패해도 이미 서명된 라이선스는 반환하되 상태를 알린다.
|
||||
let auditRecorded = false
|
||||
try {
|
||||
const auditResponse = await fetchAdminBackend(
|
||||
'/license-audit',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
licenseId: payload.licenseId,
|
||||
customerEmail: payload.customerEmail,
|
||||
tier: payload.tier,
|
||||
validity,
|
||||
expiresAt: payload.expiresAt
|
||||
})
|
||||
},
|
||||
'super_admin'
|
||||
)
|
||||
auditRecorded = auditResponse.ok
|
||||
} catch {
|
||||
auditRecorded = false
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
licenseKey,
|
||||
licenseId: payload.licenseId,
|
||||
expiresAt: payload.expiresAt,
|
||||
usedDefaultKey: signingKey.usedDefaultKey,
|
||||
auditRecorded
|
||||
})
|
||||
}
|
||||
243
apps/admin/src/app/api/admin/supabase/[operation]/route.ts
Normal file
243
apps/admin/src/app/api/admin/supabase/[operation]/route.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { AdminBackendError, requireVerifiedBackendSession } from '@/lib/backend-session'
|
||||
import { getSupabaseAdminClient } from '@/lib/supabase-admin'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
const ROLES = new Set(['user', 'manager', 'admin', 'super_admin'])
|
||||
const TIERS = new Set(['free', 'pro', 'pro_plus'])
|
||||
const STATUSES = new Set(['active', 'canceled', 'past_due', 'expired'])
|
||||
|
||||
function sameOrigin(request: NextRequest): boolean {
|
||||
const origin = request.headers.get('origin')
|
||||
if (!origin) return request.headers.get('sec-fetch-site') !== 'cross-site'
|
||||
try {
|
||||
const requestOrigin = new URL(origin).origin
|
||||
if (requestOrigin === request.nextUrl.origin) return true
|
||||
const forwardedProto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim().toLowerCase()
|
||||
const forwardedHost = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim()
|
||||
?? request.headers.get('host')?.trim()
|
||||
if ((forwardedProto !== 'http' && forwardedProto !== 'https') ||
|
||||
!forwardedHost || !/^[A-Za-z0-9.:[\]-]+$/.test(forwardedHost)) {
|
||||
return false
|
||||
}
|
||||
return requestOrigin === `${forwardedProto}://${forwardedHost}`
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function bodyObject(request: NextRequest): Promise<Record<string, unknown>> {
|
||||
if (request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') {
|
||||
throw new AdminBackendError('application_json_required', 415)
|
||||
}
|
||||
const text = await request.text()
|
||||
if (new TextEncoder().encode(text).byteLength > 32 * 1024) throw new AdminBackendError('request_too_large', 413)
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error()
|
||||
return parsed as Record<string, unknown>
|
||||
} catch {
|
||||
throw new AdminBackendError('invalid_request', 400)
|
||||
}
|
||||
}
|
||||
|
||||
function onlyKeys(body: Record<string, unknown>, allowed: readonly string[]): void {
|
||||
const keys = new Set(allowed)
|
||||
if (Object.keys(body).some((key) => !keys.has(key))) {
|
||||
throw new AdminBackendError('unexpected_request_field', 400)
|
||||
}
|
||||
}
|
||||
|
||||
function onlyQuery(request: NextRequest, allowed: readonly string[]): void {
|
||||
const keys = new Set(allowed)
|
||||
for (const key of request.nextUrl.searchParams.keys()) {
|
||||
if (!keys.has(key)) throw new AdminBackendError('unexpected_query_parameter', 400)
|
||||
}
|
||||
}
|
||||
|
||||
function memo(value: unknown): string {
|
||||
const normalized = typeof value === 'string' ? value.trim() : ''
|
||||
if (normalized.length < 3 || normalized.length > 1000) {
|
||||
throw new AdminBackendError('invalid_memo', 400)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function optionalDateTime(value: unknown): string | null {
|
||||
if (value === undefined) return null
|
||||
if (typeof value !== 'string' || value.length > 40 || !/^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
throw new AdminBackendError('invalid_current_period_end', 400)
|
||||
}
|
||||
const parsed = Date.parse(value)
|
||||
if (!Number.isFinite(parsed)) throw new AdminBackendError('invalid_current_period_end', 400)
|
||||
return new Date(parsed).toISOString()
|
||||
}
|
||||
|
||||
function idempotencyKey(request: NextRequest): string {
|
||||
const value = request.headers.get('idempotency-key')?.trim() ?? ''
|
||||
if (!UUID.test(value)) throw new AdminBackendError('valid_idempotency_key_required', 400)
|
||||
return value
|
||||
}
|
||||
|
||||
function uuid(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || !UUID.test(value)) throw new AdminBackendError(`${field}_invalid`, 400)
|
||||
return value
|
||||
}
|
||||
|
||||
function rpcFailure(error: { code?: string | null; message?: string | null }): AdminBackendError {
|
||||
const message = error.message ?? ''
|
||||
if (/admin_identity_not_linked|insufficient_admin_role|super_admin_required|admin_role_required/i.test(message) || error.code === '42501') {
|
||||
return new AdminBackendError('admin_forbidden', 403)
|
||||
}
|
||||
if (/target_user_not_found|subscription_not_found/i.test(message) || error.code === 'P0002') {
|
||||
return new AdminBackendError('record_not_found', 404)
|
||||
}
|
||||
if (/already_exists|idempotency_key_reused|operation_in_progress|cannot_demote_last/i.test(message) ||
|
||||
error.code === '23505' || error.code === '23514' || error.code === '55P03') {
|
||||
return new AdminBackendError('record_conflict', 409)
|
||||
}
|
||||
if (/^invalid_|memo_must_be|tier_required/i.test(message) || error.code === '22023') {
|
||||
return new AdminBackendError('invalid_request', 400)
|
||||
}
|
||||
return new AdminBackendError('supabase_admin_operation_failed', 500)
|
||||
}
|
||||
|
||||
async function handleUsers(request: NextRequest): Promise<NextResponse> {
|
||||
if (request.method !== 'PATCH') return NextResponse.json({ error: 'method_not_allowed' }, { status: 405 })
|
||||
onlyQuery(request, [])
|
||||
const session = await requireVerifiedBackendSession('admin')
|
||||
const body = await bodyObject(request)
|
||||
onlyKeys(body, ['userId', 'newRole', 'memo'])
|
||||
const targetUserId = uuid(body.userId, 'user_id')
|
||||
const newRole = typeof body.newRole === 'string' ? body.newRole : ''
|
||||
if (!ROLES.has(newRole)) throw new AdminBackendError('role_invalid', 400)
|
||||
const auditMemo = memo(body.memo)
|
||||
const supabase = await getSupabaseAdminClient('admin')
|
||||
const { data, error } = await supabase.rpc('admin_change_user_role_v1', {
|
||||
p_actor_email: session.email,
|
||||
p_idempotency_key: idempotencyKey(request),
|
||||
p_target_user_id: targetUserId,
|
||||
p_new_role: newRole,
|
||||
p_memo: auditMemo
|
||||
})
|
||||
if (error) throw rpcFailure(error)
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data) ||
|
||||
data.success !== true || data.userId !== targetUserId || data.newRole !== newRole) {
|
||||
throw new AdminBackendError('invalid_admin_rpc_response', 502)
|
||||
}
|
||||
return NextResponse.json({ success: true, userId: targetUserId, newRole }, { headers: { 'Cache-Control': 'no-store' } })
|
||||
}
|
||||
|
||||
async function handleSubscriptions(request: NextRequest): Promise<NextResponse> {
|
||||
if (!['POST', 'PATCH', 'DELETE'].includes(request.method)) {
|
||||
return NextResponse.json({ error: 'method_not_allowed' }, { status: 405 })
|
||||
}
|
||||
const minimumRole = request.method === 'PATCH' ? 'manager' : 'admin'
|
||||
const session = await requireVerifiedBackendSession(minimumRole)
|
||||
const body = await bodyObject(request)
|
||||
const action = request.method === 'POST' ? 'create' : request.method === 'PATCH' ? 'update' : 'delete'
|
||||
onlyQuery(request, action === 'create' ? [] : ['userId'])
|
||||
onlyKeys(body, action === 'create'
|
||||
? ['userId', 'tier', 'status', 'currentPeriodEnd', 'overageCredits', 'adminNote', 'memo']
|
||||
: action === 'update'
|
||||
? ['tier', 'status', 'currentPeriodEnd', 'overageCredits', 'adminNote', 'memo']
|
||||
: ['memo'])
|
||||
const requestedUserId = request.method === 'POST' ? body.userId : request.nextUrl.searchParams.get('userId')
|
||||
const userId = uuid(requestedUserId, 'user_id')
|
||||
const tier = typeof body.tier === 'string' ? body.tier : null
|
||||
const status = typeof body.status === 'string' ? body.status : null
|
||||
if (tier !== null && !TIERS.has(tier)) throw new AdminBackendError('tier_invalid', 400)
|
||||
if (action === 'create' && tier === null) throw new AdminBackendError('tier_required', 400)
|
||||
if (status !== null && !STATUSES.has(status)) throw new AdminBackendError('status_invalid', 400)
|
||||
const overageCredits = body.overageCredits
|
||||
if (overageCredits !== undefined &&
|
||||
(!Number.isInteger(overageCredits) || (overageCredits as number) < 0 || (overageCredits as number) > 1_000_000)) {
|
||||
throw new AdminBackendError('overage_credits_invalid', 400)
|
||||
}
|
||||
const adminNote = body.adminNote
|
||||
if (adminNote !== undefined && (typeof adminNote !== 'string' || adminNote.length > 2000)) {
|
||||
throw new AdminBackendError('admin_note_invalid', 400)
|
||||
}
|
||||
const supabase = await getSupabaseAdminClient(minimumRole)
|
||||
const { data, error } = await supabase.rpc('admin_mutate_subscription_v1', {
|
||||
p_actor_email: session.email,
|
||||
p_idempotency_key: idempotencyKey(request),
|
||||
p_action: action,
|
||||
p_user_id: userId,
|
||||
p_tier: tier,
|
||||
p_status: status,
|
||||
p_current_period_end: optionalDateTime(body.currentPeriodEnd),
|
||||
p_overage_credits: typeof overageCredits === 'number' ? overageCredits : null,
|
||||
p_admin_note: typeof adminNote === 'string' ? adminNote : null,
|
||||
p_memo: memo(body.memo)
|
||||
})
|
||||
if (error) throw rpcFailure(error)
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data) || data.success !== true ||
|
||||
!data.subscription || typeof data.subscription !== 'object' || Array.isArray(data.subscription) ||
|
||||
data.subscription.user_id !== userId || !TIERS.has(String(data.subscription.tier)) || !STATUSES.has(String(data.subscription.status))) {
|
||||
throw new AdminBackendError('invalid_admin_rpc_response', 502)
|
||||
}
|
||||
return NextResponse.json({ success: true, subscription: data.subscription }, { status: request.method === 'POST' ? 201 : 200, headers: { 'Cache-Control': 'no-store' } })
|
||||
}
|
||||
|
||||
async function handlePayments(request: NextRequest): Promise<NextResponse> {
|
||||
if (request.method !== 'GET') return NextResponse.json({ error: 'method_not_allowed' }, { status: 405 })
|
||||
await requireVerifiedBackendSession('manager')
|
||||
onlyQuery(request, ['userId', 'source'])
|
||||
const userId = uuid(request.nextUrl.searchParams.get('userId'), 'user_id')
|
||||
const source = request.nextUrl.searchParams.get('source') ?? 'db'
|
||||
if (source !== 'db' && source !== 'payple') throw new AdminBackendError('payment_source_invalid', 400)
|
||||
if (source === 'payple') {
|
||||
return NextResponse.json({ error: 'payple_live_history_not_configured' }, { status: 501 })
|
||||
}
|
||||
const supabase = await getSupabaseAdminClient()
|
||||
const [subscriptionResult, auditResult, eventsResult, operationsResult] = await Promise.all([
|
||||
supabase.from('subscriptions')
|
||||
.select('id, user_id, tier, status, provider, payment_provider, current_period_start, current_period_end, cancel_at, created_at, updated_at')
|
||||
.eq('user_id', userId).maybeSingle(),
|
||||
supabase.from('audit_log')
|
||||
.select('id, admin_id, action, target_type, target_id, memo, created_at')
|
||||
.eq('target_id', userId).eq('target_type', 'subscription').order('created_at', { ascending: false }).limit(50),
|
||||
supabase.from('payment_provider_events')
|
||||
.select('id, provider, event_type, event_created_at, disposition, received_at, processed_at')
|
||||
.eq('user_id', userId).order('event_created_at', { ascending: false }).limit(50),
|
||||
supabase.from('payment_provider_operations')
|
||||
.select('id, provider, operation_type, requested_tier, state, error_code, expires_at, created_at, updated_at')
|
||||
.eq('user_id', userId).order('created_at', { ascending: false }).limit(50)
|
||||
])
|
||||
if (subscriptionResult.error || auditResult.error || eventsResult.error || operationsResult.error) {
|
||||
throw new AdminBackendError('payment_query_failed', 500)
|
||||
}
|
||||
return NextResponse.json({
|
||||
subscription: subscriptionResult.data ?? null,
|
||||
auditLogs: auditResult.data ?? [],
|
||||
providerEvents: eventsResult.data ?? [],
|
||||
providerOperations: operationsResult.data ?? [],
|
||||
liveProviderHistoryAvailable: false
|
||||
}, { headers: { 'Cache-Control': 'no-store' } })
|
||||
}
|
||||
|
||||
async function route(request: NextRequest, context: { params: Promise<{ operation: string }> }): Promise<NextResponse> {
|
||||
if (request.method !== 'GET' && !sameOrigin(request)) {
|
||||
return NextResponse.json({ error: 'cross_site_request_rejected' }, { status: 403 })
|
||||
}
|
||||
try {
|
||||
const { operation } = await context.params
|
||||
if (operation === 'admin-users') return await handleUsers(request)
|
||||
if (operation === 'admin-subscriptions') return await handleSubscriptions(request)
|
||||
if (operation === 'admin-payments') return await handlePayments(request)
|
||||
return NextResponse.json({ error: 'admin_operation_not_allowed' }, { status: 404 })
|
||||
} catch (error) {
|
||||
if (error instanceof AdminBackendError) {
|
||||
return NextResponse.json({ error: error.message }, { status: error.status })
|
||||
}
|
||||
return NextResponse.json({ error: 'admin_operation_failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = route
|
||||
export const POST = route
|
||||
export const PATCH = route
|
||||
export const DELETE = route
|
||||
|
|
@ -1,157 +1,149 @@
|
|||
// apps/admin/src/app/api/auth/login/route.ts
|
||||
// D3RO Voice — Fortified Admin Authentication Handler
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { checkRateLimit, recordFailedAttempt, resetFailedAttempts, signSession } from '@/lib/security'
|
||||
import {
|
||||
checkRateLimit,
|
||||
recordFailedAttempt,
|
||||
resetFailedAttempts,
|
||||
signSession
|
||||
} from '@/lib/security'
|
||||
import { adminCookieSecure } from '@/lib/admin-session'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
interface BackendAuthResponse {
|
||||
token?: unknown
|
||||
email?: unknown
|
||||
role?: unknown
|
||||
expiresAt?: unknown
|
||||
}
|
||||
|
||||
type AdminRole = 'manager' | 'admin' | 'super_admin'
|
||||
|
||||
function apiBase(): string {
|
||||
const value = process.env.API_SERVER_URL?.trim()
|
||||
if (!value) throw new Error('admin_auth_unavailable')
|
||||
const parsed = new URL(value)
|
||||
if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') {
|
||||
throw new Error('admin_auth_unavailable')
|
||||
}
|
||||
return parsed.origin
|
||||
}
|
||||
|
||||
function normalizeRole(value: unknown): AdminRole | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const normalized = value.replace(/[_-]/g, '').toLowerCase()
|
||||
if (normalized === 'superadmin') return 'super_admin'
|
||||
if (normalized === 'admin') return 'admin'
|
||||
if (normalized === 'manager') return 'manager'
|
||||
return null
|
||||
}
|
||||
|
||||
function clientIdentifier(request: Request, email: string): string {
|
||||
const forwarded =
|
||||
request.headers.get('x-forwarded-for') ?? request.headers.get('cf-connecting-ip') ?? 'unknown'
|
||||
return `${forwarded.split(',')[0].trim().slice(0, 64)}:${email}`
|
||||
}
|
||||
|
||||
function failure(key: string, status: number, retryAfterSeconds?: number): NextResponse {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: key,
|
||||
...(retryAfterSeconds ? { retryAfter: retryAfterSeconds } : {})
|
||||
},
|
||||
{
|
||||
status,
|
||||
headers: retryAfterSeconds ? { 'Retry-After': String(retryAfterSeconds) } : undefined
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<NextResponse> {
|
||||
let body: unknown
|
||||
try {
|
||||
// 1. Extract Client IP & Identifier for Rate Limiting
|
||||
const forwardedFor = request.headers.get('x-forwarded-for') || request.headers.get('cf-connecting-ip') || '127.0.0.1'
|
||||
const clientIp = forwardedFor.split(',')[0].trim()
|
||||
|
||||
const body = await request.json()
|
||||
const { usernameOrEmail, password, trap } = body
|
||||
|
||||
// 2. Honeypot Bot Trap: If hidden bot field is filled, silently reject
|
||||
if (trap) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
return NextResponse.json({ success: false, message: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!usernameOrEmail || !password) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '아이디와 비밀번호를 입력해주세요.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const trimmedUser = String(usernameOrEmail).trim().toLowerCase()
|
||||
const trimmedPass = String(password).trim()
|
||||
const rateLimitKey = `${clientIp}:${trimmedUser}`
|
||||
|
||||
// 3. Check Sliding-Window Rate Limiter
|
||||
const rateCheck = checkRateLimit(rateLimitKey)
|
||||
if (!rateCheck.allowed) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `로그인 시도 횟수를 초과했습니다. 보안을 위해 ${rateCheck.retryAfterSeconds}초 후 다시 시도해주세요.`,
|
||||
retryAfter: rateCheck.retryAfterSeconds,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: { 'Retry-After': String(rateCheck.retryAfterSeconds) },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let authenticated = false
|
||||
let userRole = 'super_admin'
|
||||
let userEmail = 'admin@d3ro.voice'
|
||||
let token = ''
|
||||
|
||||
// 4. Master Admin Verification (admin / Test1234!)
|
||||
if (
|
||||
(trimmedUser === 'admin' || trimmedUser === 'admin@d3ro.voice' || trimmedUser === 'admin@d3ro.dev') &&
|
||||
trimmedPass === 'Test1234!'
|
||||
) {
|
||||
authenticated = true
|
||||
userRole = 'super_admin'
|
||||
userEmail = 'admin@d3ro.voice'
|
||||
token = `d3ro_tok_${Date.now()}`
|
||||
}
|
||||
|
||||
// 5. Backend C# API Verification
|
||||
if (!authenticated) {
|
||||
try {
|
||||
const apiRes = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: trimmedUser, password: trimmedPass }),
|
||||
})
|
||||
|
||||
if (apiRes.ok) {
|
||||
const data = await apiRes.json()
|
||||
if (data.token) {
|
||||
authenticated = true
|
||||
token = data.token
|
||||
userEmail = data.email || trimmedUser
|
||||
userRole = (data.role || '').toLowerCase() === 'admin' ? 'admin' : 'super_admin'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback catch
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Handle Authentication Failure (Anti-Brute Force Tracking)
|
||||
if (!authenticated) {
|
||||
const lockResult = recordFailedAttempt(rateLimitKey)
|
||||
// Dynamic delay to prevent timing analysis
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
|
||||
if (lockResult.locked) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `5회 이상 잘못된 비밀번호가 입력되었습니다. 보안을 위해 계정이 ${lockResult.retryAfterSeconds}초 동안 잠깁니다.`,
|
||||
retryAfter: lockResult.retryAfterSeconds,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: { 'Retry-After': String(lockResult.retryAfterSeconds) },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: false, message: '아이디 또는 비밀번호가 올바르지 않습니다.' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// 7. Successful Authentication -> Clear Rate Limiting
|
||||
resetFailedAttempts(rateLimitKey)
|
||||
|
||||
// 8. Generate Cryptographically Signed HMAC Session Token
|
||||
const sessionData = {
|
||||
id: 'admin-usr-1',
|
||||
username: 'admin',
|
||||
email: userEmail,
|
||||
role: userRole,
|
||||
token,
|
||||
loginAt: new Date().toISOString(),
|
||||
expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
}
|
||||
|
||||
const signedCookieValue = signSession(sessionData)
|
||||
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: sessionData.id,
|
||||
email: sessionData.email,
|
||||
role: sessionData.role,
|
||||
},
|
||||
})
|
||||
|
||||
// 9. Set Hardened HttpOnly SameSite=Strict Cookie
|
||||
response.cookies.set({
|
||||
name: 'd3ro_admin_session',
|
||||
value: signedCookieValue,
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal server error'
|
||||
return NextResponse.json({ success: false, message }, { status: 500 })
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return failure('invalid_request', 400)
|
||||
}
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return failure('invalid_request', 400)
|
||||
}
|
||||
|
||||
const candidate = body as Record<string, unknown>
|
||||
if (candidate.trap) return failure('access_denied', 403)
|
||||
const email =
|
||||
typeof candidate.usernameOrEmail === 'string'
|
||||
? candidate.usernameOrEmail.trim().toLowerCase()
|
||||
: ''
|
||||
const password = typeof candidate.password === 'string' ? candidate.password : ''
|
||||
if (!email || email.length > 150 || !password || password.length > 256) {
|
||||
return failure('invalid_request', 400)
|
||||
}
|
||||
|
||||
const rateLimitKey = clientIdentifier(request, email)
|
||||
const rateCheck = checkRateLimit(rateLimitKey)
|
||||
if (!rateCheck.allowed) {
|
||||
return failure('rate_limited', 429, rateCheck.retryAfterSeconds)
|
||||
}
|
||||
|
||||
let backend: BackendAuthResponse
|
||||
try {
|
||||
const response = await fetch(`${apiBase()}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(7_000)
|
||||
})
|
||||
if (!response.ok) throw new Error('invalid_credentials')
|
||||
backend = (await response.json()) as BackendAuthResponse
|
||||
} catch {
|
||||
const lock = recordFailedAttempt(rateLimitKey)
|
||||
if (!lock.locked) await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
return lock.locked
|
||||
? failure('rate_limited', 429, lock.retryAfterSeconds)
|
||||
: failure('invalid_credentials', 401)
|
||||
}
|
||||
|
||||
const role = normalizeRole(backend.role)
|
||||
const token = typeof backend.token === 'string' ? backend.token : ''
|
||||
const authenticatedEmail =
|
||||
typeof backend.email === 'string' ? backend.email.trim().toLowerCase() : ''
|
||||
const backendExpiry =
|
||||
typeof backend.expiresAt === 'string' ? Date.parse(backend.expiresAt) : Number.NaN
|
||||
if (
|
||||
!role ||
|
||||
token.length < 80 ||
|
||||
token.length > 8_192 ||
|
||||
!authenticatedEmail ||
|
||||
authenticatedEmail !== email ||
|
||||
!Number.isFinite(backendExpiry) ||
|
||||
backendExpiry <= Date.now()
|
||||
) {
|
||||
return failure('invalid_auth_response', 502)
|
||||
}
|
||||
|
||||
resetFailedAttempts(rateLimitKey)
|
||||
const expiresAt = Math.min(backendExpiry, Date.now() + 8 * 60 * 60 * 1_000)
|
||||
const signedCookieValue = signSession({
|
||||
email: authenticatedEmail,
|
||||
role,
|
||||
token,
|
||||
loginAt: new Date().toISOString(),
|
||||
expiresAt
|
||||
})
|
||||
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
user: { email: authenticatedEmail, role }
|
||||
})
|
||||
response.cookies.set({
|
||||
name: 'd3ro_admin_session',
|
||||
value: signedCookieValue,
|
||||
httpOnly: true,
|
||||
secure: adminCookieSecure(),
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: Math.max(1, Math.floor((expiresAt - Date.now()) / 1_000))
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// D3RO Voice — Admin Session Logout Handler
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { adminCookieSecure } from '@/lib/admin-session'
|
||||
|
||||
export async function POST(): Promise<NextResponse> {
|
||||
const response = NextResponse.json({ success: true, message: 'Logged out successfully' })
|
||||
|
|
@ -10,7 +11,7 @@ export async function POST(): Promise<NextResponse> {
|
|||
name: 'd3ro_admin_session',
|
||||
value: '',
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: adminCookieSecure(),
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
|
|
@ -28,7 +29,7 @@ export async function GET(request: Request): Promise<NextResponse> {
|
|||
name: 'd3ro_admin_session',
|
||||
value: '',
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: adminCookieSecure(),
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
|
|
|
|||
|
|
@ -1,36 +1,21 @@
|
|||
/* D3RO Voice Admin CRM — "Midnight Glass v2" Global Styles */
|
||||
|
||||
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap');
|
||||
|
||||
:root {
|
||||
--d3-bg-base: #070b16;
|
||||
--d3-bg-app: #0a0e1c;
|
||||
--d3-bg-card: #111a30;
|
||||
--d3-bg-card-hover: #152039;
|
||||
--d3-bg-elevated: #1a2540;
|
||||
--d3-bg-input: #0d1526;
|
||||
--d3-bg-sidebar: #0b101f;
|
||||
--d3-border: rgba(148, 180, 255, 0.08);
|
||||
--d3-border-hl: rgba(148, 180, 255, 0.16);
|
||||
--d3-accent: #3b82f6;
|
||||
--d3-accent-light: #60a5fa;
|
||||
--d3-cyan: #06b6d4;
|
||||
--d3-purple: #8b5cf6;
|
||||
--text-dim: #67789e;
|
||||
--text-base: #93a4c8;
|
||||
--text-bright: #eef2fb;
|
||||
}
|
||||
/* 폰트는 layout.tsx <head>의 Pretendard Variable 다이나믹 서브셋 링크로 로드한다
|
||||
(렌더를 막는 @import 이중 로드는 제거).
|
||||
색 변수는 @d3ro/ui SSOT가 MuiCssBaseline로 주입하는 --d3-*를 그대로 쓴다
|
||||
(관리자는 고정 다크: 값 미지정 시 아래 폴백). */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 의미적 강조는 세미볼드(600)까지 — 볼드(700+) 타이포그래피 금지 */
|
||||
strong, b { font-weight: 600; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--d3-bg-base);
|
||||
color: var(--text-bright);
|
||||
background-color: var(--d3-bg-app, #070b16);
|
||||
color: var(--d3-text-primary, #eef2fb);
|
||||
font-family: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
|
|
|||
|
|
@ -21,10 +21,10 @@ export default function RootLayout({
|
|||
<head>
|
||||
<title>D3RO Voice — Admin & Intelligence CRM</title>
|
||||
<meta name="description" content="D3RO Voice AI Voice Assistant Administration Console" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css" />
|
||||
{/* Pretendard Variable 다이나믹 서브셋 — 화면에 쓰인 글리프만 분할 로드 (한글 웹폰트 표준).
|
||||
mono는 시스템 스택(ui-monospace)을 쓰므로 별도 웹폰트를 로드하지 않는다. */}
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossOrigin="" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css" />
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
<div className="admin-ambient-glow" />
|
||||
|
|
|
|||
|
|
@ -17,15 +17,33 @@ function LoginForm(): React.ReactElement {
|
|||
const searchParams = useSearchParams()
|
||||
const redirectPath = searchParams.get('redirect') || '/'
|
||||
|
||||
const [usernameOrEmail, setUsernameOrEmail] = useState('admin')
|
||||
const [password, setPassword] = useState('Test1234!')
|
||||
const [usernameOrEmail, setUsernameOrEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
|
||||
const describeLoginError = (errorKey: unknown, retryAfter: unknown): string => {
|
||||
if (errorKey === 'rate_limited') {
|
||||
const seconds = typeof retryAfter === 'number' && retryAfter > 0 ? retryAfter : null
|
||||
return seconds
|
||||
? `로그인 시도가 제한되었습니다. ${Math.ceil(seconds / 60)}분 후 다시 시도해주세요.`
|
||||
: '로그인 시도가 제한되었습니다. 잠시 후 다시 시도해주세요.'
|
||||
}
|
||||
if (errorKey === 'invalid_credentials') return '이메일 또는 비밀번호가 올바르지 않습니다.'
|
||||
if (errorKey === 'invalid_auth_response' || errorKey === 'admin_auth_unavailable') {
|
||||
return '인증 서버 응답이 올바르지 않습니다. 관리자에게 문의해주세요.'
|
||||
}
|
||||
return '인증에 실패했습니다.'
|
||||
}
|
||||
|
||||
const handleLogin = async (e?: React.FormEvent): Promise<void> => {
|
||||
if (e) e.preventDefault()
|
||||
if (!usernameOrEmail || !password) {
|
||||
setErrorMsg('아이디와 비밀번호를 입력해주세요.')
|
||||
setErrorMsg('이메일과 비밀번호를 입력해주세요.')
|
||||
return
|
||||
}
|
||||
if (!usernameOrEmail.includes('@')) {
|
||||
setErrorMsg('관리자 이메일 주소로 로그인해주세요. (username 로그인은 지원이 종료되었습니다)')
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +60,7 @@ function LoginForm(): React.ReactElement {
|
|||
const data = await res.json()
|
||||
|
||||
if (!res.ok || !data.success) {
|
||||
throw new Error(data.message || '인증에 실패했습니다.')
|
||||
throw new Error(describeLoginError(data.error, data.retryAfter))
|
||||
}
|
||||
|
||||
// Successful login
|
||||
|
|
@ -117,7 +135,7 @@ function LoginForm(): React.ReactElement {
|
|||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '22px',
|
||||
fontWeight: 800,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '-0.02em',
|
||||
color: C.bright,
|
||||
lineHeight: 1.2,
|
||||
|
|
@ -165,13 +183,14 @@ function LoginForm(): React.ReactElement {
|
|||
{/* Login Form */}
|
||||
<Box component="form" onSubmit={handleLogin} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Admin Identifier (Username / Email)"
|
||||
label="Admin Email"
|
||||
type="email"
|
||||
value={usernameOrEmail}
|
||||
onChange={(e) => setUsernameOrEmail(e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
required
|
||||
autoComplete="username"
|
||||
autoComplete="email"
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
bgcolor: 'rgba(10, 17, 31, 0.8)',
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export default function UnauthorizedPage(): React.ReactElement {
|
|||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: '22px',
|
||||
fontWeight: 800,
|
||||
fontWeight: 600,
|
||||
color: C.bright,
|
||||
mb: 1,
|
||||
}}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue