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
72
.env.example
Normal file
72
.env.example
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
# Copy this file to .env for local operations only. Never commit populated values.
|
||||||
|
|
||||||
|
# NAS deployment target
|
||||||
|
DSM_HOST=
|
||||||
|
DSM_SSH_PORT=22
|
||||||
|
DSM_SSH_USER=
|
||||||
|
DSM_SSH_PASSWORD=
|
||||||
|
NAS_DEPLOY_ROOT=/volume1/docker/d3ro-voice
|
||||||
|
NAS_ADMIN_PORT=5000
|
||||||
|
NAS_LANDING_PORT=4321
|
||||||
|
|
||||||
|
# Git/Forgejo operations
|
||||||
|
GIT_SERVER_URL=https://git.example.invalid
|
||||||
|
GIT_USERNAME=
|
||||||
|
GIT_PASSWORD=
|
||||||
|
GIT_REPO_NAME=d3ro-voice
|
||||||
|
FORGEJO_TOKEN=
|
||||||
|
FORGEJO_USERNAME=
|
||||||
|
FORGEJO_PASSWORD=
|
||||||
|
|
||||||
|
# Public service addresses
|
||||||
|
APP_DOMAIN=voice.example.invalid
|
||||||
|
ADMIN_DOMAIN=admin.voice.example.invalid
|
||||||
|
API_SERVER_URL=https://voice.example.invalid
|
||||||
|
SITE_URL=https://voice.example.invalid
|
||||||
|
CORS_ALLOWED_ORIGINS=https://voice.example.invalid,https://admin.voice.example.invalid
|
||||||
|
ALLOWED_HOSTS=voice.example.invalid;admin.voice.example.invalid
|
||||||
|
|
||||||
|
# API authentication. Generate unique random values for every environment.
|
||||||
|
# JWT_SECRET and ADMIN_SESSION_SECRET must each be at least 32 UTF-8 bytes.
|
||||||
|
JWT_SECRET=
|
||||||
|
JWT_ISSUER=https://voice.example.invalid
|
||||||
|
JWT_AUDIENCE=d3ro-admin
|
||||||
|
ADMIN_BOOTSTRAP_TOKEN=
|
||||||
|
ADMIN_SESSION_SECRET=
|
||||||
|
# TLS 없이 LAN HTTP(예: http://NAS_IP:3001)로 직접 접속하는 배포에서만 false로 설정.
|
||||||
|
# Secure 쿠키는 HTTP에서 브라우저가 버려 로그인이 유지되지 않는다. 기본값 true 유지 권장.
|
||||||
|
ADMIN_COOKIE_SECURE=true
|
||||||
|
# Shared only between Supabase stt-proxy and the .NET provider orchestrator.
|
||||||
|
# Use a distinct random value with at least 32 UTF-8 bytes.
|
||||||
|
D3RO_API_TOKEN=
|
||||||
|
|
||||||
|
# Supabase
|
||||||
|
SUPABASE_URL=
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=
|
||||||
|
|
||||||
|
# Google OAuth and browser automation
|
||||||
|
GOOGLE_OAUTH_CLIENT_ID=
|
||||||
|
GOOGLE_OAUTH_SECRET=
|
||||||
|
D3RO_PORTAL_OPERATOR_EMAIL=
|
||||||
|
|
||||||
|
# Android release signing. Prefer Gradle properties or a protected CI secret store.
|
||||||
|
D3RO_RELEASE_STORE_FILE=
|
||||||
|
D3RO_RELEASE_STORE_PASSWORD=
|
||||||
|
D3RO_RELEASE_KEY_ALIAS=
|
||||||
|
D3RO_RELEASE_KEY_PASSWORD=
|
||||||
|
|
||||||
|
# Google Mobile Ads production identifiers. These are identifiers, not secrets,
|
||||||
|
# but release builds intentionally fail when they are absent or use test IDs.
|
||||||
|
D3RO_ADMOB_APP_ID=
|
||||||
|
D3RO_ADMOB_BANNER_UNIT_ID=
|
||||||
|
D3RO_ADMOB_REWARDED_UNIT_ID=
|
||||||
|
|
||||||
|
# Email and push providers
|
||||||
|
RESEND_FROM=
|
||||||
|
RESEND_API_KEY=
|
||||||
|
FCM_SERVICE_ACCOUNT_JSON=
|
||||||
|
FCM_PROJECT_ID=
|
||||||
|
|
||||||
|
# Optional authenticated mobile E2E account, stored only in CI secrets or local .env.
|
||||||
|
MOBILE_E2E_EMAIL=
|
||||||
|
MOBILE_E2E_PASSWORD=
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
# apps/admin/Dockerfile
|
# apps/admin/Dockerfile
|
||||||
FROM node:20-alpine AS base
|
FROM node:24-alpine AS base
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json tsconfig*.json ./
|
COPY package*.json tsconfig*.json ./
|
||||||
COPY packages ./packages
|
COPY packages ./packages
|
||||||
COPY apps/admin ./apps/admin
|
COPY apps/admin ./apps/admin
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
RUN npm run build --workspace=@d3ro/admin
|
RUN npm run build --workspace=@d3ro/admin
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|
@ -17,6 +17,7 @@ ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
COPY --from=builder /app/apps/admin/.next/standalone ./
|
COPY --from=builder /app/apps/admin/.next/standalone ./
|
||||||
COPY --from=builder /app/apps/admin/.next/static ./apps/admin/.next/static
|
COPY --from=builder /app/apps/admin/.next/static ./apps/admin/.next/static
|
||||||
|
COPY apps/admin/start.mjs ./apps/admin/start.mjs
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
CMD ["node", "apps/admin/server.js"]
|
CMD ["node", "apps/admin/start.mjs"]
|
||||||
|
|
|
||||||
289
apps/admin/__tests__/admin-edge.local.integration.mjs
Normal file
289
apps/admin/__tests__/admin-edge.local.integration.mjs
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { createClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
|
const url = process.env.D3RO_LOCAL_SUPABASE_URL?.trim() ?? ''
|
||||||
|
const anonKey = process.env.D3RO_LOCAL_SUPABASE_ANON_KEY?.trim() ?? ''
|
||||||
|
const serviceRoleKey = process.env.D3RO_LOCAL_SUPABASE_SERVICE_ROLE_KEY?.trim() ?? ''
|
||||||
|
if (!url.startsWith('http://127.0.0.1:55321') || anonKey.length < 20 || serviceRoleKey.length < 20) {
|
||||||
|
throw new Error('D3RO local Supabase credentials for port 55321 are required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientOptions = { auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false } }
|
||||||
|
const service = createClient(url, serviceRoleKey, clientOptions)
|
||||||
|
const suffix = randomUUID()
|
||||||
|
const createdUserIds = []
|
||||||
|
let assertions = 0
|
||||||
|
|
||||||
|
function checked(value, message) {
|
||||||
|
assert.ok(value, message)
|
||||||
|
assertions += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createActor(label, role) {
|
||||||
|
const email = `d3ro.edge.${suffix}.${label}@example.com`
|
||||||
|
const password = `D3ro-${suffix}-${label}-Strong!`
|
||||||
|
const { data, error } = await service.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
email_confirm: true,
|
||||||
|
app_metadata: { role },
|
||||||
|
user_metadata: { name: `Edge ${label}` }
|
||||||
|
})
|
||||||
|
assert.equal(error, null, `${label} creation failed: ${error?.message ?? ''}`)
|
||||||
|
createdUserIds.push(data.user.id)
|
||||||
|
const profile = await service.from('profiles').update({ role }).eq('id', data.user.id)
|
||||||
|
assert.equal(profile.error, null, `${label} profile setup failed`)
|
||||||
|
|
||||||
|
const login = createClient(url, anonKey, clientOptions)
|
||||||
|
const session = await login.auth.signInWithPassword({ email, password })
|
||||||
|
assert.equal(session.error, null, `${label} sign-in failed`)
|
||||||
|
return { id: data.user.id, email, token: session.data.session.access_token }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invoke(functionName, options = {}) {
|
||||||
|
const headers = { apikey: anonKey, Accept: 'application/json' }
|
||||||
|
if (options.token) headers.Authorization = `Bearer ${options.token}`
|
||||||
|
if (options.body !== undefined) headers['Content-Type'] = 'application/json'
|
||||||
|
if (options.idempotencyKey) headers['Idempotency-Key'] = options.idempotencyKey
|
||||||
|
const response = await fetch(`${url}/functions/v1/${functionName}${options.query ?? ''}`, {
|
||||||
|
method: options.method ?? 'GET',
|
||||||
|
headers,
|
||||||
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||||
|
})
|
||||||
|
const text = await response.text()
|
||||||
|
let payload = null
|
||||||
|
try {
|
||||||
|
payload = text ? JSON.parse(text) : null
|
||||||
|
} catch {
|
||||||
|
payload = { invalidJson: text }
|
||||||
|
}
|
||||||
|
return { status: response.status, payload }
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireStatus(result, status, code, label) {
|
||||||
|
assert.equal(result.status, status, `${label}: ${JSON.stringify(result.payload)}`)
|
||||||
|
if (code) assert.equal(result.payload?.error, code, `${label}: wrong stable error code`)
|
||||||
|
assertions += code ? 2 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = await createActor('manager', 'manager')
|
||||||
|
const staleManager = await createActor('stale-manager', 'manager')
|
||||||
|
const admin = await createActor('admin', 'admin')
|
||||||
|
const superAdmin = await createActor('super', 'super_admin')
|
||||||
|
const target = await createActor('target', 'user')
|
||||||
|
|
||||||
|
const anonymous = await invoke('admin-users')
|
||||||
|
checked(anonymous.status === 401, `anonymous request must be denied, received ${anonymous.status}`)
|
||||||
|
|
||||||
|
requireStatus(
|
||||||
|
await invoke('admin-users', { token: target.token }),
|
||||||
|
403,
|
||||||
|
'admin_forbidden',
|
||||||
|
'ordinary user list access',
|
||||||
|
)
|
||||||
|
const staleProfileDemotion = await service.from('profiles').update({ role: 'user' }).eq('id', staleManager.id)
|
||||||
|
assert.equal(staleProfileDemotion.error, null, 'stale manager profile demotion failed')
|
||||||
|
const staleAuthDemotion = await service.auth.admin.updateUserById(staleManager.id, { app_metadata: { role: 'user' } })
|
||||||
|
assert.equal(staleAuthDemotion.error, null, 'stale manager auth demotion failed')
|
||||||
|
requireStatus(
|
||||||
|
await invoke('admin-users', { token: staleManager.token }),
|
||||||
|
403,
|
||||||
|
'admin_forbidden',
|
||||||
|
'demoted manager stale token read',
|
||||||
|
)
|
||||||
|
requireStatus(
|
||||||
|
await invoke('admin-users', { token: manager.token, query: '?page=NaN&limit=20' }),
|
||||||
|
400,
|
||||||
|
'invalid_pagination',
|
||||||
|
'NaN pagination',
|
||||||
|
)
|
||||||
|
requireStatus(
|
||||||
|
await invoke('admin-users', { token: manager.token, query: '?debug=true' }),
|
||||||
|
400,
|
||||||
|
'unexpected_query_parameter',
|
||||||
|
'unknown query parameter',
|
||||||
|
)
|
||||||
|
const detail = await invoke('admin-users', { token: manager.token, query: `?userId=${target.id}` })
|
||||||
|
requireStatus(detail, 200, null, 'manager user detail')
|
||||||
|
checked(detail.payload?.account?.email === target.email, 'user detail must come from the real auth account')
|
||||||
|
|
||||||
|
requireStatus(
|
||||||
|
await invoke('admin-users', {
|
||||||
|
token: manager.token,
|
||||||
|
method: 'PATCH',
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
body: { userId: target.id, newRole: 'manager', memo: 'manager escalation attempt' },
|
||||||
|
}),
|
||||||
|
403,
|
||||||
|
'admin_forbidden',
|
||||||
|
'manager role mutation',
|
||||||
|
)
|
||||||
|
|
||||||
|
const roleKey = randomUUID()
|
||||||
|
const roleBody = { userId: target.id, newRole: 'manager', memo: 'verified support assignment' }
|
||||||
|
const roleChanged = await invoke('admin-users', {
|
||||||
|
token: admin.token,
|
||||||
|
method: 'PATCH',
|
||||||
|
idempotencyKey: roleKey,
|
||||||
|
body: roleBody,
|
||||||
|
})
|
||||||
|
requireStatus(roleChanged, 200, null, 'admin role mutation')
|
||||||
|
checked(roleChanged.payload?.newRole === 'manager', 'role response must match the requested role')
|
||||||
|
const roleReplay = await invoke('admin-users', {
|
||||||
|
token: admin.token,
|
||||||
|
method: 'PATCH',
|
||||||
|
idempotencyKey: roleKey,
|
||||||
|
body: roleBody,
|
||||||
|
})
|
||||||
|
requireStatus(roleReplay, 200, null, 'role idempotent replay')
|
||||||
|
checked(JSON.stringify(roleReplay.payload) === JSON.stringify(roleChanged.payload), 'idempotent response changed')
|
||||||
|
const { count: roleAuditCount } = await service.from('audit_log').select('id', { count: 'exact', head: true })
|
||||||
|
.eq('admin_id', admin.id).eq('action', 'user.role_change').eq('target_id', target.id)
|
||||||
|
checked(roleAuditCount === 1, 'role replay duplicated audit rows')
|
||||||
|
|
||||||
|
requireStatus(
|
||||||
|
await invoke('admin-users', {
|
||||||
|
token: admin.token,
|
||||||
|
method: 'PATCH',
|
||||||
|
idempotencyKey: roleKey,
|
||||||
|
body: { ...roleBody, memo: 'different request using the same key' },
|
||||||
|
}),
|
||||||
|
409,
|
||||||
|
'idempotency_conflict',
|
||||||
|
'role idempotency mismatch',
|
||||||
|
)
|
||||||
|
requireStatus(
|
||||||
|
await invoke('admin-users', {
|
||||||
|
token: admin.token,
|
||||||
|
method: 'PATCH',
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
body: { userId: target.id, newRole: 'admin', memo: 'unauthorized privileged role assignment' },
|
||||||
|
}),
|
||||||
|
403,
|
||||||
|
'super_admin_required',
|
||||||
|
'admin privileged role assignment',
|
||||||
|
)
|
||||||
|
|
||||||
|
const superRole = await invoke('admin-users', {
|
||||||
|
token: superAdmin.token,
|
||||||
|
method: 'PATCH',
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
body: { userId: target.id, newRole: 'admin', memo: 'approved privileged role assignment' },
|
||||||
|
})
|
||||||
|
requireStatus(superRole, 200, null, 'super admin privileged role assignment')
|
||||||
|
|
||||||
|
const subscriptionRemoval = await service.from('subscriptions').delete().eq('user_id', target.id)
|
||||||
|
assert.equal(subscriptionRemoval.error, null, 'target subscription fixture removal failed')
|
||||||
|
const subscriptionKey = randomUUID()
|
||||||
|
const subscriptionBody = {
|
||||||
|
userId: target.id,
|
||||||
|
tier: 'pro',
|
||||||
|
status: 'active',
|
||||||
|
overageCredits: 25,
|
||||||
|
adminNote: 'verified support grant',
|
||||||
|
memo: 'approved manual subscription grant',
|
||||||
|
}
|
||||||
|
const subscriptionCreated = await invoke('admin-subscriptions', {
|
||||||
|
token: admin.token,
|
||||||
|
method: 'POST',
|
||||||
|
idempotencyKey: subscriptionKey,
|
||||||
|
body: subscriptionBody,
|
||||||
|
})
|
||||||
|
requireStatus(subscriptionCreated, 201, null, 'subscription creation')
|
||||||
|
checked(!JSON.stringify(subscriptionCreated.payload).includes('provider_resource_id'), 'subscription mutation exposed provider resource id')
|
||||||
|
checked(!JSON.stringify(subscriptionCreated.payload).includes('payple_payer_id'), 'subscription mutation exposed Payple payer id')
|
||||||
|
const subscriptionReplay = await invoke('admin-subscriptions', {
|
||||||
|
token: admin.token,
|
||||||
|
method: 'POST',
|
||||||
|
idempotencyKey: subscriptionKey,
|
||||||
|
body: subscriptionBody,
|
||||||
|
})
|
||||||
|
requireStatus(subscriptionReplay, 201, null, 'subscription create replay')
|
||||||
|
checked(JSON.stringify(subscriptionReplay.payload) === JSON.stringify(subscriptionCreated.payload), 'subscription replay response changed')
|
||||||
|
|
||||||
|
const updated = await invoke('admin-subscriptions', {
|
||||||
|
token: manager.token,
|
||||||
|
method: 'PATCH',
|
||||||
|
query: `?userId=${target.id}`,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
body: { tier: 'pro_plus', overageCredits: 50, memo: 'verified entitlement correction' },
|
||||||
|
})
|
||||||
|
requireStatus(updated, 200, null, 'manager subscription update')
|
||||||
|
checked(updated.payload?.subscription?.tier === 'pro_plus', 'updated tier must be returned from the RPC')
|
||||||
|
|
||||||
|
requireStatus(
|
||||||
|
await invoke('admin-subscriptions', {
|
||||||
|
token: manager.token,
|
||||||
|
method: 'DELETE',
|
||||||
|
query: `?userId=${target.id}`,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
body: { memo: 'manager delete attempt' },
|
||||||
|
}),
|
||||||
|
403,
|
||||||
|
'admin_forbidden',
|
||||||
|
'manager subscription delete',
|
||||||
|
)
|
||||||
|
requireStatus(
|
||||||
|
await invoke('admin-subscriptions', {
|
||||||
|
token: manager.token,
|
||||||
|
method: 'PATCH',
|
||||||
|
query: `?userId=${target.id}`,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
body: { overageCredits: -1, memo: 'invalid negative credit update' },
|
||||||
|
}),
|
||||||
|
400,
|
||||||
|
'invalid_overageCredits',
|
||||||
|
'negative overage credits',
|
||||||
|
)
|
||||||
|
const { data: unchanged } = await service.from('subscriptions').select('overage_credits').eq('user_id', target.id).single()
|
||||||
|
checked(unchanged?.overage_credits === 50, 'failed update changed subscription data')
|
||||||
|
|
||||||
|
const paymentHistory = await invoke('admin-payments', { token: manager.token, query: `?userId=${target.id}` })
|
||||||
|
requireStatus(paymentHistory, 200, null, 'database payment history')
|
||||||
|
checked(Array.isArray(paymentHistory.payload?.providerEvents), 'provider event ledger must be returned as an array')
|
||||||
|
checked(!JSON.stringify(paymentHistory.payload).includes('payload_digest'), 'payment payload digest must not be exposed')
|
||||||
|
checked(!JSON.stringify(paymentHistory.payload).includes('provider_resource_id'), 'provider resource identifier must not be exposed')
|
||||||
|
requireStatus(
|
||||||
|
await invoke('admin-payments', { token: manager.token, query: `?userId=${target.id}&source=payple` }),
|
||||||
|
501,
|
||||||
|
'payple_live_history_not_configured',
|
||||||
|
'live Payple history',
|
||||||
|
)
|
||||||
|
|
||||||
|
const sensitiveAudit = await service.from('audit_log').insert({
|
||||||
|
admin_id: admin.id,
|
||||||
|
action: 'subscription.redaction_test',
|
||||||
|
target_type: 'subscription',
|
||||||
|
target_id: target.id,
|
||||||
|
before_data: { tier: 'pro', payple_payer_id: 'payer-secret' },
|
||||||
|
after_data: { tier: 'pro_plus', provider_resource_id: 'resource-secret', payload: { raw: 'provider-payload' } },
|
||||||
|
memo: 'verify audit response redaction',
|
||||||
|
})
|
||||||
|
assert.equal(sensitiveAudit.error, null, 'sensitive audit fixture insertion failed')
|
||||||
|
const audit = await invoke('admin-audit-log', { token: manager.token, query: `?target_id=${target.id}&limit=100` })
|
||||||
|
requireStatus(audit, 200, null, 'audit log read')
|
||||||
|
checked(audit.payload?.logs?.some((entry) => entry.action === 'user.role_change'), 'role audit row is missing')
|
||||||
|
checked(audit.payload?.logs?.some((entry) => entry.action === 'subscription.update'), 'subscription audit row is missing')
|
||||||
|
const encodedAudit = JSON.stringify(audit.payload)
|
||||||
|
checked(!encodedAudit.includes('payer-secret') && !encodedAudit.includes('resource-secret') && !encodedAudit.includes('provider-payload'), 'audit response exposed provider identifiers or payload')
|
||||||
|
|
||||||
|
const deleted = await invoke('admin-subscriptions', {
|
||||||
|
token: admin.token,
|
||||||
|
method: 'DELETE',
|
||||||
|
query: `?userId=${target.id}`,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
body: { memo: 'approved subscription retirement' },
|
||||||
|
})
|
||||||
|
requireStatus(deleted, 200, null, 'admin subscription soft delete')
|
||||||
|
checked(deleted.payload?.subscription?.tier === 'free' && deleted.payload?.subscription?.status === 'expired', 'soft delete response is invalid')
|
||||||
|
|
||||||
|
console.log(`admin Edge local integration: ${assertions} assertions passed`)
|
||||||
|
} finally {
|
||||||
|
if (createdUserIds.length > 0) {
|
||||||
|
await service.from('audit_log').delete().in('admin_id', createdUserIds)
|
||||||
|
await service.from('admin_operation_requests').delete().in('actor_id', createdUserIds)
|
||||||
|
for (const userId of createdUserIds.reverse()) await service.auth.admin.deleteUser(userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
194
apps/admin/__tests__/admin-management.local.integration.mjs
Normal file
194
apps/admin/__tests__/admin-management.local.integration.mjs
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { createClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
|
const url = process.env.D3RO_LOCAL_SUPABASE_URL?.trim() ?? ''
|
||||||
|
const anonKey = process.env.D3RO_LOCAL_SUPABASE_ANON_KEY?.trim() ?? ''
|
||||||
|
const serviceRoleKey = process.env.D3RO_LOCAL_SUPABASE_SERVICE_ROLE_KEY?.trim() ?? ''
|
||||||
|
if (!url.startsWith('http://127.0.0.1:55321') || anonKey.length < 20 || serviceRoleKey.length < 20) {
|
||||||
|
throw new Error('D3RO local Supabase credentials for port 55321 are required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = { auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false } }
|
||||||
|
const service = createClient(url, serviceRoleKey, options)
|
||||||
|
const anonymous = createClient(url, anonKey, options)
|
||||||
|
const suffix = randomUUID()
|
||||||
|
const createdUserIds = []
|
||||||
|
let assertions = 0
|
||||||
|
|
||||||
|
function checked(value, message) {
|
||||||
|
assert.ok(value, message)
|
||||||
|
assertions += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createActor(label, role) {
|
||||||
|
const email = `d3ro.admin.${suffix}.${label}@example.com`
|
||||||
|
const { data, error } = await service.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
password: `D3ro-${suffix}-${label}-Strong!`,
|
||||||
|
email_confirm: true,
|
||||||
|
app_metadata: { role },
|
||||||
|
user_metadata: { name: label }
|
||||||
|
})
|
||||||
|
assert.equal(error, null, `${label} creation failed: ${error?.message ?? ''}`)
|
||||||
|
createdUserIds.push(data.user.id)
|
||||||
|
const profileUpdate = await service.from('profiles').update({ role }).eq('id', data.user.id)
|
||||||
|
assert.equal(profileUpdate.error, null, `${label} profile role setup failed`)
|
||||||
|
return { id: data.user.id, email }
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireSuccess(result, label) {
|
||||||
|
assert.equal(result.error, null, `${label}: ${result.error?.message ?? 'unknown error'}`)
|
||||||
|
checked(result.data?.success === true, `${label}: success response missing`)
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireFailure(result, pattern, label) {
|
||||||
|
assert.ok(result.error, `${label}: unexpectedly succeeded`)
|
||||||
|
assert.match(`${result.error.code ?? ''} ${result.error.message ?? ''}`, pattern, `${label}: wrong error`)
|
||||||
|
assertions += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const superAdmin = await createActor('super', 'super_admin')
|
||||||
|
const admin = await createActor('admin', 'admin')
|
||||||
|
const manager = await createActor('manager', 'manager')
|
||||||
|
const target = await createActor('target', 'user')
|
||||||
|
|
||||||
|
requireFailure(await anonymous.rpc('admin_change_user_role_v1', {
|
||||||
|
p_actor_email: admin.email,
|
||||||
|
p_idempotency_key: randomUUID(),
|
||||||
|
p_target_user_id: target.id,
|
||||||
|
p_new_role: 'manager',
|
||||||
|
p_memo: 'anonymous bypass attempt'
|
||||||
|
}), /permission denied|42501/i, 'anonymous RPC execution')
|
||||||
|
|
||||||
|
requireFailure(await service.rpc('admin_change_user_role_v1', {
|
||||||
|
p_actor_email: manager.email,
|
||||||
|
p_idempotency_key: randomUUID(),
|
||||||
|
p_target_user_id: target.id,
|
||||||
|
p_new_role: 'manager',
|
||||||
|
p_memo: 'manager escalation attempt'
|
||||||
|
}), /insufficient_admin_role|42501/i, 'manager role mutation')
|
||||||
|
|
||||||
|
const roleKey = randomUUID()
|
||||||
|
const roleArgs = {
|
||||||
|
p_actor_email: admin.email,
|
||||||
|
p_idempotency_key: roleKey,
|
||||||
|
p_target_user_id: target.id,
|
||||||
|
p_new_role: 'manager',
|
||||||
|
p_memo: 'verified customer support assignment'
|
||||||
|
}
|
||||||
|
const roleChanged = requireSuccess(await service.rpc('admin_change_user_role_v1', roleArgs), 'admin role change')
|
||||||
|
const roleReplayed = requireSuccess(await service.rpc('admin_change_user_role_v1', roleArgs), 'role idempotent replay')
|
||||||
|
checked(JSON.stringify(roleChanged) === JSON.stringify(roleReplayed), 'role replay response changed')
|
||||||
|
|
||||||
|
const [{ data: targetProfile }, { data: targetAuth }, { count: roleAuditCount }] = await Promise.all([
|
||||||
|
service.from('profiles').select('role').eq('id', target.id).single(),
|
||||||
|
service.auth.admin.getUserById(target.id),
|
||||||
|
service.from('audit_log').select('id', { count: 'exact', head: true }).eq('admin_id', admin.id).eq('action', 'user.role_change').eq('target_id', target.id)
|
||||||
|
])
|
||||||
|
checked(targetProfile?.role === 'manager', 'profile role was not updated')
|
||||||
|
checked(targetAuth?.user?.app_metadata?.role === 'manager', 'auth app_metadata role was not updated')
|
||||||
|
checked(roleAuditCount === 1, 'idempotent role replay duplicated audit rows')
|
||||||
|
|
||||||
|
requireFailure(await service.rpc('admin_change_user_role_v1', {
|
||||||
|
...roleArgs,
|
||||||
|
p_memo: 'different request with reused key'
|
||||||
|
}), /idempotency_key_reused|22023/i, 'role idempotency mismatch')
|
||||||
|
|
||||||
|
requireFailure(await service.rpc('admin_change_user_role_v1', {
|
||||||
|
p_actor_email: admin.email,
|
||||||
|
p_idempotency_key: randomUUID(),
|
||||||
|
p_target_user_id: target.id,
|
||||||
|
p_new_role: 'admin',
|
||||||
|
p_memo: 'unauthorized privileged assignment'
|
||||||
|
}), /super_admin_required|42501/i, 'admin privileged role assignment')
|
||||||
|
|
||||||
|
requireSuccess(await service.rpc('admin_change_user_role_v1', {
|
||||||
|
p_actor_email: superAdmin.email,
|
||||||
|
p_idempotency_key: randomUUID(),
|
||||||
|
p_target_user_id: target.id,
|
||||||
|
p_new_role: 'admin',
|
||||||
|
p_memo: 'approved privileged assignment'
|
||||||
|
}), 'super admin privileged assignment')
|
||||||
|
|
||||||
|
await service.from('subscriptions').delete().eq('user_id', target.id)
|
||||||
|
const subscriptionKey = randomUUID()
|
||||||
|
const createArgs = {
|
||||||
|
p_actor_email: admin.email,
|
||||||
|
p_idempotency_key: subscriptionKey,
|
||||||
|
p_action: 'create',
|
||||||
|
p_user_id: target.id,
|
||||||
|
p_tier: 'pro',
|
||||||
|
p_status: 'active',
|
||||||
|
p_overage_credits: 25,
|
||||||
|
p_admin_note: 'verified support grant',
|
||||||
|
p_memo: 'approved manual subscription grant'
|
||||||
|
}
|
||||||
|
const createdSubscription = requireSuccess(await service.rpc('admin_mutate_subscription_v1', createArgs), 'subscription create')
|
||||||
|
checked(!JSON.stringify(createdSubscription).includes('provider_resource_id'), 'subscription RPC exposed provider resource id')
|
||||||
|
checked(!JSON.stringify(createdSubscription).includes('payple_payer_id'), 'subscription RPC exposed Payple payer id')
|
||||||
|
requireSuccess(await service.rpc('admin_mutate_subscription_v1', createArgs), 'subscription create replay')
|
||||||
|
const { count: createAuditCount } = await service.from('audit_log').select('id', { count: 'exact', head: true })
|
||||||
|
.eq('admin_id', admin.id).eq('action', 'subscription.create').eq('target_id', target.id)
|
||||||
|
checked(createAuditCount === 1, 'subscription replay duplicated audit rows')
|
||||||
|
|
||||||
|
requireSuccess(await service.rpc('admin_mutate_subscription_v1', {
|
||||||
|
p_actor_email: manager.email,
|
||||||
|
p_idempotency_key: randomUUID(),
|
||||||
|
p_action: 'update',
|
||||||
|
p_user_id: target.id,
|
||||||
|
p_tier: 'pro_plus',
|
||||||
|
p_status: 'active',
|
||||||
|
p_overage_credits: 50,
|
||||||
|
p_memo: 'customer support entitlement correction'
|
||||||
|
}), 'manager subscription update')
|
||||||
|
const [{ data: updatedSubscription }, { data: updatedProfile }] = await Promise.all([
|
||||||
|
service.from('subscriptions').select('tier, overage_credits').eq('user_id', target.id).single(),
|
||||||
|
service.from('profiles').select('tier').eq('id', target.id).single()
|
||||||
|
])
|
||||||
|
checked(updatedSubscription?.tier === 'pro_plus' && updatedSubscription?.overage_credits === 50, 'subscription update was not persisted')
|
||||||
|
checked(updatedProfile?.tier === 'pro_plus', 'profile tier was not synchronized')
|
||||||
|
|
||||||
|
requireFailure(await service.rpc('admin_mutate_subscription_v1', {
|
||||||
|
p_actor_email: manager.email,
|
||||||
|
p_idempotency_key: randomUUID(),
|
||||||
|
p_action: 'delete',
|
||||||
|
p_user_id: target.id,
|
||||||
|
p_memo: 'manager delete attempt'
|
||||||
|
}), /admin_role_required|42501/i, 'manager subscription delete')
|
||||||
|
|
||||||
|
requireFailure(await service.rpc('admin_mutate_subscription_v1', {
|
||||||
|
p_actor_email: admin.email,
|
||||||
|
p_idempotency_key: randomUUID(),
|
||||||
|
p_action: 'update',
|
||||||
|
p_user_id: target.id,
|
||||||
|
p_overage_credits: -1,
|
||||||
|
p_memo: 'invalid negative credit mutation'
|
||||||
|
}), /invalid_overage_credits|22023/i, 'negative credits validation')
|
||||||
|
const { data: unchanged } = await service.from('subscriptions').select('overage_credits').eq('user_id', target.id).single()
|
||||||
|
checked(unchanged?.overage_credits === 50, 'failed mutation changed subscription data')
|
||||||
|
|
||||||
|
requireSuccess(await service.rpc('admin_mutate_subscription_v1', {
|
||||||
|
p_actor_email: admin.email,
|
||||||
|
p_idempotency_key: randomUUID(),
|
||||||
|
p_action: 'delete',
|
||||||
|
p_user_id: target.id,
|
||||||
|
p_memo: 'approved subscription retirement'
|
||||||
|
}), 'admin subscription soft delete')
|
||||||
|
const [{ data: deletedSubscription }, { data: deletedProfile }] = await Promise.all([
|
||||||
|
service.from('subscriptions').select('tier, status').eq('user_id', target.id).single(),
|
||||||
|
service.from('profiles').select('tier').eq('id', target.id).single()
|
||||||
|
])
|
||||||
|
checked(deletedSubscription?.tier === 'free' && deletedSubscription?.status === 'expired', 'soft delete state is invalid')
|
||||||
|
checked(deletedProfile?.tier === 'free', 'soft delete did not synchronize profile tier')
|
||||||
|
|
||||||
|
console.log(`admin management integration: ${assertions} assertions passed`)
|
||||||
|
} finally {
|
||||||
|
if (createdUserIds.length > 0) {
|
||||||
|
await service.from('audit_log').delete().in('admin_id', createdUserIds)
|
||||||
|
await service.from('admin_operation_requests').delete().in('actor_id', createdUserIds)
|
||||||
|
for (const userId of createdUserIds.reverse()) await service.auth.admin.deleteUser(userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
101
apps/admin/__tests__/admin.local.browser.e2e.mjs
Normal file
101
apps/admin/__tests__/admin.local.browser.e2e.mjs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { chromium } from 'playwright'
|
||||||
|
|
||||||
|
const baseUrl = process.env.D3RO_ADMIN_E2E_URL?.trim() ?? ''
|
||||||
|
if (!baseUrl.startsWith('https://127.0.0.1:')) throw new Error('Local HTTPS admin E2E URL is required')
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true })
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, ignoreHTTPSErrors: true })
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.alert = () => undefined
|
||||||
|
window.confirm = () => true
|
||||||
|
window.prompt = () => 'browser verified endpoint deletion'
|
||||||
|
})
|
||||||
|
const consoleErrors = []
|
||||||
|
const serverErrors = []
|
||||||
|
page.on('console', (message) => {
|
||||||
|
if (message.type() === 'error') consoleErrors.push(message.text())
|
||||||
|
})
|
||||||
|
page.on('response', (response) => {
|
||||||
|
if (response.status() >= 500) serverErrors.push(`${response.status()} ${response.url()}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(`${baseUrl}/`, { waitUntil: 'networkidle' })
|
||||||
|
await page.getByLabel('Admin Identifier (Username / Email)').fill('admin.browser.e2e@example.com')
|
||||||
|
await page.getByLabel('Password').fill('Admin-browser-e2e-strong-password!')
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForURL(`${baseUrl}/`),
|
||||||
|
page.getByRole('button', { name: 'Sign In as Super Admin' }).click()
|
||||||
|
])
|
||||||
|
await page.getByText('BACKEND CONNECTED').waitFor()
|
||||||
|
|
||||||
|
const endpointPayload = await page.evaluate(async () => {
|
||||||
|
const response = await fetch('/api/admin/backend/endpoints', { cache: 'no-store' })
|
||||||
|
return { status: response.status, body: await response.json() }
|
||||||
|
})
|
||||||
|
assert.equal(endpointPayload.status, 200)
|
||||||
|
assert.ok(Array.isArray(endpointPayload.body))
|
||||||
|
assert.ok(endpointPayload.body.every((endpoint) => endpoint.apiKey === '' || endpoint.apiKey === '••••••••'))
|
||||||
|
|
||||||
|
await page.goto(`${baseUrl}/models`, { waitUntil: 'networkidle' })
|
||||||
|
await page.getByRole('tab', { name: /LLM & Reasoning Model Endpoints/ }).click()
|
||||||
|
const addModelButton = page.getByRole('button', { name: /Add LLM Model Endpoint/ })
|
||||||
|
if (await addModelButton.count() === 0) {
|
||||||
|
const cookies = await page.context().cookies()
|
||||||
|
const bodyText = (await page.locator('body').innerText()).slice(0, 1200)
|
||||||
|
throw new Error(`models diagnostic url=${page.url()} cookies=${cookies.map((cookie) => `${cookie.name}:${cookie.secure}:${cookie.sameSite}`).join(',')} body=${bodyText}`)
|
||||||
|
}
|
||||||
|
await addModelButton.click()
|
||||||
|
await page.getByLabel('Model ID').fill('browser-e2e-model')
|
||||||
|
await page.getByLabel('Display Name').fill('Browser E2E Model')
|
||||||
|
await page.getByLabel('Endpoint URL').fill('https://models.example.com/v1')
|
||||||
|
await page.getByLabel('Audit memo').last().fill('browser verified endpoint creation')
|
||||||
|
const createResponse = page.waitForResponse((response) => response.url().includes('/api/admin/backend/endpoints') && response.request().method() === 'POST')
|
||||||
|
await page.getByRole('button', { name: 'Save Endpoint' }).click()
|
||||||
|
const created = await createResponse
|
||||||
|
assert.equal(created.status(), 200, `model creation failed: ${await created.text()}`)
|
||||||
|
await page.getByText('Browser E2E Model').waitFor()
|
||||||
|
|
||||||
|
const modelRow = page.locator('tr').filter({ hasText: 'browser-e2e-model' })
|
||||||
|
const deleteResponse = page.waitForResponse((response) => response.url().includes('/api/admin/backend/endpoints/') && response.request().method() === 'DELETE')
|
||||||
|
await modelRow.getByRole('button', { name: 'Delete' }).click()
|
||||||
|
const deleted = await deleteResponse
|
||||||
|
assert.equal(deleted.status(), 200, `model deletion failed: ${await deleted.text()}`)
|
||||||
|
await page.getByText('Browser E2E Model').waitFor({ state: 'detached' })
|
||||||
|
|
||||||
|
await page.goto(`${baseUrl}/users`, { waitUntil: 'networkidle' })
|
||||||
|
const customerRow = page.locator('tr').filter({ hasText: 'customer.browser.e2e@example.com' })
|
||||||
|
await customerRow.waitFor()
|
||||||
|
const customerHref = await customerRow.getByRole('link').first().getAttribute('href')
|
||||||
|
assert.match(customerHref ?? '', /^\/users\/[0-9a-f-]{36}$/)
|
||||||
|
const customerId = customerHref.split('/').pop()
|
||||||
|
|
||||||
|
await page.goto(`${baseUrl}${customerHref}`, { waitUntil: 'networkidle' })
|
||||||
|
await page.getByRole('button', { name: 'Change Role' }).click()
|
||||||
|
const roleDialog = page.getByRole('dialog', { name: /CHANGE USER ROLE/ })
|
||||||
|
await roleDialog.waitFor()
|
||||||
|
await roleDialog.getByRole('combobox').click()
|
||||||
|
await page.getByRole('option', { name: 'manager' }).click()
|
||||||
|
await page.getByPlaceholder('Reason for role change (required)...').fill('browser verified support assignment')
|
||||||
|
await page.getByRole('button', { name: 'Change Role', exact: true }).last().click()
|
||||||
|
await page.getByText('MANAGER').first().waitFor()
|
||||||
|
|
||||||
|
await page.goto(`${baseUrl}/subscriptions/${customerId}`, { waitUntil: 'networkidle' })
|
||||||
|
await page.getByRole('combobox', { name: 'Tier' }).click()
|
||||||
|
await page.getByRole('option', { name: 'PRO', exact: true }).click()
|
||||||
|
await page.getByLabel('Memo (required for audit log)').fill('browser verified subscription update')
|
||||||
|
await page.getByRole('button', { name: 'Update' }).click()
|
||||||
|
await page.getByText('Operation successful').waitFor()
|
||||||
|
|
||||||
|
await page.goto(`${baseUrl}/audit-log`, { waitUntil: 'networkidle' })
|
||||||
|
await page.getByText('user.role_change').first().waitFor()
|
||||||
|
await page.getByText('subscription.update').first().waitFor()
|
||||||
|
await page.screenshot({ path: 'scratch/admin-browser-e2e.png', fullPage: true })
|
||||||
|
|
||||||
|
assert.deepEqual(serverErrors, [], `server errors: ${serverErrors.join(', ')}`)
|
||||||
|
assert.deepEqual(consoleErrors, [], `console errors: ${consoleErrors.join(', ')}`)
|
||||||
|
console.log('admin browser E2E: login, JWT proxy, model create/delete, users, role, subscription, audit GREEN')
|
||||||
|
} finally {
|
||||||
|
await browser.close()
|
||||||
|
}
|
||||||
57
apps/admin/__tests__/browser-fixture.local.mjs
Normal file
57
apps/admin/__tests__/browser-fixture.local.mjs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { createClient } from '@supabase/supabase-js'
|
||||||
|
|
||||||
|
const action = process.argv[2]
|
||||||
|
const url = process.env.D3RO_LOCAL_SUPABASE_URL?.trim() ?? ''
|
||||||
|
const serviceRoleKey = process.env.D3RO_LOCAL_SUPABASE_SERVICE_ROLE_KEY?.trim() ?? ''
|
||||||
|
if (!url.startsWith('http://127.0.0.1:55321') || serviceRoleKey.length < 20) {
|
||||||
|
throw new Error('D3RO local Supabase service credentials are required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createClient(url, serviceRoleKey, {
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false }
|
||||||
|
})
|
||||||
|
const adminEmail = 'admin.browser.e2e@example.com'
|
||||||
|
const targetEmail = 'customer.browser.e2e@example.com'
|
||||||
|
|
||||||
|
async function matchingUsers() {
|
||||||
|
const { data, error } = await client.auth.admin.listUsers({ page: 1, perPage: 1000 })
|
||||||
|
if (error) throw error
|
||||||
|
return data.users.filter((user) => user.email === adminEmail || user.email === targetEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
const users = await matchingUsers()
|
||||||
|
const ids = users.map((user) => user.id)
|
||||||
|
if (ids.length === 0) return
|
||||||
|
await client.from('audit_log').delete().in('admin_id', ids)
|
||||||
|
await client.from('admin_operation_requests').delete().in('actor_id', ids)
|
||||||
|
for (const user of users) await client.auth.admin.deleteUser(user.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'cleanup') {
|
||||||
|
await cleanup()
|
||||||
|
console.log('admin browser fixture cleaned')
|
||||||
|
} else if (action === 'setup') {
|
||||||
|
await cleanup()
|
||||||
|
const admin = await client.auth.admin.createUser({
|
||||||
|
email: adminEmail,
|
||||||
|
password: 'Admin-browser-e2e-strong-password!',
|
||||||
|
email_confirm: true,
|
||||||
|
app_metadata: { role: 'super_admin' },
|
||||||
|
user_metadata: { name: 'Browser E2E Admin' }
|
||||||
|
})
|
||||||
|
if (admin.error) throw admin.error
|
||||||
|
const target = await client.auth.admin.createUser({
|
||||||
|
email: targetEmail,
|
||||||
|
password: 'Customer-browser-e2e-strong-password!',
|
||||||
|
email_confirm: true,
|
||||||
|
app_metadata: { role: 'user' },
|
||||||
|
user_metadata: { name: 'Browser E2E Customer' }
|
||||||
|
})
|
||||||
|
if (target.error) throw target.error
|
||||||
|
const roleUpdate = await client.from('profiles').update({ role: 'super_admin' }).eq('id', admin.data.user.id)
|
||||||
|
if (roleUpdate.error) throw roleUpdate.error
|
||||||
|
console.log(`admin browser fixture ready target=${target.data.user.id}`)
|
||||||
|
} else {
|
||||||
|
throw new Error('Expected setup or cleanup')
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,8 @@
|
||||||
"@mui/material-nextjs": "^7.0.0",
|
"@mui/material-nextjs": "^7.0.0",
|
||||||
"@supabase/ssr": "^0.10.0",
|
"@supabase/ssr": "^0.10.0",
|
||||||
"@supabase/supabase-js": "^2.103.0",
|
"@supabase/supabase-js": "^2.103.0",
|
||||||
"next": "^15.0.0",
|
"lucide-react": "^1.33.0",
|
||||||
|
"next": "16.3.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"recharts": "^2.15.0"
|
"recharts": "^2.15.0"
|
||||||
|
|
|
||||||
|
|
@ -1,562 +1,280 @@
|
||||||
// apps/admin/src/app/(admin)/ads/page.tsx
|
// 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 } from '@mui/material'
|
||||||
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'
|
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 {
|
export const dynamic = 'force-dynamic'
|
||||||
id: string
|
|
||||||
name: string
|
const FORMAT_LABEL: Record<string, string> = {
|
||||||
adapterType: string
|
banner_dock: 'Bottom Dock',
|
||||||
format: string
|
rewarded_video: 'Rewarded Video',
|
||||||
impressions: number
|
export_sponsor: 'Export Sponsor',
|
||||||
clicks: number
|
audio_chime: 'Audio Chime'
|
||||||
ctr: string
|
|
||||||
ecpm: number
|
|
||||||
grossRevenueUsd: number
|
|
||||||
fillRate: string
|
|
||||||
status: 'active' | 'bidding' | 'fallback'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettlementRow {
|
function formatDateTime(value: string): string {
|
||||||
id: string
|
const parsed = new Date(value)
|
||||||
cycleMonth: string
|
return Number.isNaN(parsed.getTime()) ? '—' : parsed.toISOString().replace('T', ' ').slice(0, 16)
|
||||||
networkName: string
|
|
||||||
grossUsd: number
|
|
||||||
withholdingTax: string
|
|
||||||
netPayoutKrw: number
|
|
||||||
payoutStatus: 'settled' | 'paid' | 'pending'
|
|
||||||
method: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminAdsPage(): Promise<React.ReactElement> {
|
function HeaderBar(): 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)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Box
|
||||||
{/* Header */}
|
sx={{
|
||||||
<Box
|
...panelSx,
|
||||||
sx={{
|
minHeight: 84,
|
||||||
...panelSx,
|
px: { xs: 2.5, md: 4 },
|
||||||
minHeight: 84,
|
py: 2,
|
||||||
px: { xs: 2.5, md: 4 },
|
display: 'flex',
|
||||||
py: 2,
|
flexWrap: 'wrap',
|
||||||
display: 'flex',
|
alignItems: 'center',
|
||||||
flexWrap: 'wrap',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
gap: 2
|
||||||
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>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="h5"
|
component="h1"
|
||||||
sx={{
|
sx={{ fontFamily: FONT_SANS, fontSize: '20px', fontWeight: 500, color: C.bright, letterSpacing: '-0.02em', m: 0 }}
|
||||||
fontFamily: FONT_SANS,
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: { xs: '20px', md: '24px' },
|
|
||||||
color: C.bright,
|
|
||||||
letterSpacing: '-0.02em',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Multi-Ad Mediation & Revenue Settlement Hub
|
Ad Mediation & Monetization
|
||||||
</Typography>
|
</Typography>
|
||||||
<TactileBadge mono tone="accent">10 NETWORKS ACTIVE</TactileBadge>
|
<TactileBadge tone="warning" mono>
|
||||||
<TactileBadge mono tone="success">AUCTION HEALTHY</TactileBadge>
|
FAIL-CLOSED SANDBOX
|
||||||
|
</TactileBadge>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, letterSpacing: '0.04em', mt: 0.25 }}>
|
||||||
sx={{
|
Mediation waterfall roster, rewarded token grants, network integration state
|
||||||
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>
|
</Typography>
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
|
</Box>
|
||||||
{/* Publisher Account Banner */}
|
)
|
||||||
<Box
|
}
|
||||||
sx={{
|
|
||||||
...panelSx,
|
function RewardKpis({ stats }: { stats: AdRewardStats }): React.ReactElement {
|
||||||
p: 2.5,
|
const cards = [
|
||||||
display: 'flex',
|
{
|
||||||
flexWrap: 'wrap',
|
title: `Rewarded Claims (${stats.windowDays}d)`,
|
||||||
alignItems: 'center',
|
value: stats.totalClaims.toLocaleString(),
|
||||||
justifyContent: 'space-between',
|
subtext: 'Verified rewarded-video completions'
|
||||||
gap: 2,
|
},
|
||||||
bgcolor: 'rgba(14, 165, 233, 0.05)',
|
{
|
||||||
border: '1px solid rgba(56, 189, 248, 0.2)',
|
title: `Tokens Granted (${stats.windowDays}d)`,
|
||||||
}}
|
value: stats.totalRewardTokens.toLocaleString(),
|
||||||
>
|
subtext: 'Cloud AI tokens issued via ad rewards'
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
},
|
||||||
<Box
|
{
|
||||||
sx={{
|
title: `Unique Claimants (${stats.windowDays}d)`,
|
||||||
width: 40,
|
value: stats.uniqueClaimants.toLocaleString(),
|
||||||
height: 40,
|
subtext: 'Distinct accounts that redeemed rewards'
|
||||||
borderRadius: '10px',
|
},
|
||||||
bgcolor: 'rgba(56, 189, 248, 0.15)',
|
{
|
||||||
display: 'flex',
|
title: 'Live Ad Networks',
|
||||||
alignItems: 'center',
|
value: `0 / ${MEDIATION_ROSTER.length}`,
|
||||||
justifyContent: 'center',
|
subtext: 'All adapters fail-closed until SDK contracts land'
|
||||||
color: C.cyanLight,
|
}
|
||||||
fontWeight: 800,
|
]
|
||||||
fontSize: '18px',
|
return (
|
||||||
}}
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' }, gap: 2 }}>
|
||||||
>
|
{cards.map((card) => (
|
||||||
P
|
<Box key={card.title} sx={{ ...panelSx, p: 2.5 }}>
|
||||||
</Box>
|
<Typography
|
||||||
<Box>
|
sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mb: 1, textTransform: 'uppercase', letterSpacing: '0.08em' }}
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontWeight: 700, fontSize: '14px', color: C.bright }}>
|
>
|
||||||
Registered Publisher Account: yunchanpaca@gmail.com
|
{card.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '22px', fontWeight: 700, color: C.bright, lineHeight: 1.25 }}>
|
||||||
Payout Beneficiary: D3RO Voice AI • KB국민은행 928702-00-184920 • 사업자등록 120-88-01923
|
{card.value}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>{card.subtext}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
))}
|
||||||
<TactileBadge mono tone="success">KYC Verified</TactileBadge>
|
</Box>
|
||||||
<TactileBadge mono tone="accent">3.3% 원천징수 적용</TactileBadge>
|
)
|
||||||
</Box>
|
}
|
||||||
</Box>
|
|
||||||
|
function MediationRosterPanel(): React.ReactElement {
|
||||||
{/* KPI Cards */}
|
return (
|
||||||
<Box
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
sx={{
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1.5, mb: 1 }}>
|
||||||
display: 'grid',
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright }}>
|
||||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' },
|
Mediation Waterfall Roster
|
||||||
gap: 2,
|
</Typography>
|
||||||
}}
|
<Box sx={statusBadgeSx('orange')}>NO LIVE BIDS</Box>
|
||||||
>
|
</Box>
|
||||||
<DoubleBezelCard interactive>
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 2 }}>
|
||||||
<Box sx={{ p: 2.5 }}>
|
데스크톱 미디에이션 엔진에 등록된 어댑터 구성입니다. 공식 SDK/인증 계약이 연결되기 전까지 모든 네트워크는
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
|
입찰 없이 fail-closed로 동작하며, 데모 크리에이티브와 가짜 수익 수치는 표시하지 않습니다.
|
||||||
Est. Monthly Ad Revenue
|
</Typography>
|
||||||
</Typography>
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.cyanLight, my: 0.5 }}>
|
<Box component="table" sx={tableSx}>
|
||||||
${totalGrossRevenue.toFixed(2)}
|
<Box component="thead">
|
||||||
</Typography>
|
<Box component="tr">
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.green400 }}>
|
<Box component="th">Network</Box>
|
||||||
₩{(totalGrossRevenue * 1350).toLocaleString()} (환율 ₩1,350)
|
<Box component="th">Adapter ID</Box>
|
||||||
</Typography>
|
<Box component="th">Formats</Box>
|
||||||
</Box>
|
<Box component="th">Floor eCPM</Box>
|
||||||
</DoubleBezelCard>
|
<Box component="th">Integration</Box>
|
||||||
|
</Box>
|
||||||
<DoubleBezelCard interactive>
|
</Box>
|
||||||
<Box sx={{ p: 2.5 }}>
|
<Box component="tbody">
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
|
{MEDIATION_ROSTER.map((network) => (
|
||||||
Weighted Avg. eCPM
|
<Box component="tr" key={network.networkId}>
|
||||||
</Typography>
|
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.bright }}>
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.bright, my: 0.5 }}>
|
{network.name}
|
||||||
${avgEcpm}
|
</Box>
|
||||||
</Typography>
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight }}>
|
{network.networkId}
|
||||||
Floor: $2.00 min • Max: $18.00
|
</Box>
|
||||||
</Typography>
|
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '12px' }}>
|
||||||
</Box>
|
{network.formats.map((format) => FORMAT_LABEL[format] ?? format).join(' · ')}
|
||||||
</DoubleBezelCard>
|
</Box>
|
||||||
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
|
||||||
<DoubleBezelCard interactive>
|
${network.floorEcpm.toFixed(2)}
|
||||||
<Box sx={{ p: 2.5 }}>
|
</Box>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
|
<Box component="td">
|
||||||
Total Ad Impressions
|
<Box sx={statusBadgeSx('orange')}>NOT INTEGRATED</Box>
|
||||||
</Typography>
|
</Box>
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.bright, my: 0.5 }}>
|
</Box>
|
||||||
{(totalImpressions / 1000).toFixed(1)}k
|
))}
|
||||||
</Typography>
|
</Box>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
</Box>
|
||||||
Avg. Fill Rate: 96.8%
|
</Box>
|
||||||
</Typography>
|
</DoubleBezelCard>
|
||||||
</Box>
|
)
|
||||||
</DoubleBezelCard>
|
}
|
||||||
|
|
||||||
<DoubleBezelCard interactive>
|
function RewardBreakdownPanel({ stats }: { stats: AdRewardStats }): React.ReactElement {
|
||||||
<Box sx={{ p: 2.5 }}>
|
return (
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1fr 1.4fr' }, gap: 3 }}>
|
||||||
Net Payout Settled (KRW)
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
</Typography>
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright, mb: 2 }}>
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.green400, my: 0.5 }}>
|
Rewarded Grants by Network
|
||||||
₩{(totalSettledKrw / 10000).toFixed(1)}만
|
</Typography>
|
||||||
</Typography>
|
{stats.byNetwork.length === 0 ? (
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.green400 }}>
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>
|
||||||
₩{totalSettledKrw.toLocaleString()} 입금 완료
|
최근 {stats.windowDays}일간 검증된 리워드 클레임이 없습니다.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
) : (
|
||||||
</DoubleBezelCard>
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
</Box>
|
<Box component="table" sx={tableSx}>
|
||||||
|
<Box component="thead">
|
||||||
{/* 10+ Multi-Ad Network Mediation Matrix */}
|
<Box component="tr">
|
||||||
<Box sx={{ ...panelSx, p: { xs: 2, md: 3 } }}>
|
<Box component="th">Network</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
<Box component="th">Claims</Box>
|
||||||
<Box>
|
<Box component="th">Tokens</Box>
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
</Box>
|
||||||
10+ Active Ad Networks & Header Bidding Matrix
|
</Box>
|
||||||
</Typography>
|
<Box component="tbody">
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
{stats.byNetwork.map((summary) => (
|
||||||
First-price real-time bidding auction with sub-800ms SLA fallback to Direct House AI Sponsors.
|
<Box component="tr" key={summary.network}>
|
||||||
</Typography>
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
|
||||||
</Box>
|
{summary.network}
|
||||||
<TactileBadge mono tone="default">AUCTION TIMEOUT: 800MS</TactileBadge>
|
</Box>
|
||||||
</Box>
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
|
||||||
|
{summary.claims.toLocaleString()}
|
||||||
<Box sx={{ overflowX: 'auto' }}>
|
</Box>
|
||||||
<Box component="table" sx={tableSx}>
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.green400 }}>
|
||||||
<thead>
|
+{summary.rewardTokens.toLocaleString()}
|
||||||
<tr>
|
</Box>
|
||||||
<th style={{ textAlign: 'left' }}>Demand Partner</th>
|
</Box>
|
||||||
<th style={{ textAlign: 'left' }}>Adapter Protocol</th>
|
))}
|
||||||
<th style={{ textAlign: 'left' }}>Primary Slot Format</th>
|
</Box>
|
||||||
<th style={{ textAlign: 'right' }}>Impressions</th>
|
</Box>
|
||||||
<th style={{ textAlign: 'right' }}>CTR</th>
|
</Box>
|
||||||
<th style={{ textAlign: 'right' }}>Bid eCPM</th>
|
)}
|
||||||
<th style={{ textAlign: 'right' }}>Gross Revenue</th>
|
</DoubleBezelCard>
|
||||||
<th style={{ textAlign: 'center' }}>Fill Rate</th>
|
|
||||||
<th style={{ textAlign: 'center' }}>Status</th>
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
</tr>
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright, mb: 2 }}>
|
||||||
</thead>
|
Recent Verified Claims
|
||||||
<tbody>
|
</Typography>
|
||||||
{networks.map((net) => (
|
{stats.recentClaims.length === 0 ? (
|
||||||
<tr key={net.id}>
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>표시할 클레임이 없습니다.</Typography>
|
||||||
<td style={{ fontWeight: 600, color: C.bright }}>{net.name}</td>
|
) : (
|
||||||
<td style={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight }}>{net.adapterType}</td>
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
<td style={{ color: C.dim, fontSize: '12px' }}>{net.format}</td>
|
<Box component="table" sx={tableSx}>
|
||||||
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, color: C.bright }}>
|
<Box component="thead">
|
||||||
{net.impressions.toLocaleString()}
|
<Box component="tr">
|
||||||
</td>
|
<Box component="th">Verified At (UTC)</Box>
|
||||||
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, color: C.green400 }}>{net.ctr}</td>
|
<Box component="th">Network</Box>
|
||||||
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, fontWeight: 700, color: C.cyanLight }}>
|
<Box component="th">Placement</Box>
|
||||||
${net.ecpm.toFixed(2)}
|
<Box component="th">Tokens</Box>
|
||||||
</td>
|
</Box>
|
||||||
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, fontWeight: 700, color: C.bright }}>
|
</Box>
|
||||||
${net.grossRevenueUsd.toFixed(2)}
|
<Box component="tbody">
|
||||||
</td>
|
{stats.recentClaims.map((claim) => (
|
||||||
<td style={{ textAlign: 'center', fontFamily: FONT_MONO, color: C.dim }}>{net.fillRate}</td>
|
<Box component="tr" key={claim.id}>
|
||||||
<td style={{ textAlign: 'center' }}>
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
|
||||||
<Box
|
{formatDateTime(claim.verifiedAt)}
|
||||||
component="span"
|
</Box>
|
||||||
sx={{
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
|
||||||
...statusBadgeSx,
|
{claim.network}
|
||||||
bgcolor:
|
</Box>
|
||||||
net.status === 'active'
|
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '12px' }}>{claim.placement}</Box>
|
||||||
? 'rgba(34, 197, 94, 0.12)'
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.green400 }}>
|
||||||
: 'rgba(56, 189, 248, 0.12)',
|
+{claim.rewardTokens.toLocaleString()}
|
||||||
color: net.status === 'active' ? C.green400 : C.cyanLight,
|
</Box>
|
||||||
borderColor:
|
</Box>
|
||||||
net.status === 'active' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(56, 189, 248, 0.3)',
|
))}
|
||||||
}}
|
</Box>
|
||||||
>
|
</Box>
|
||||||
{net.status.toUpperCase()}
|
</Box>
|
||||||
</Box>
|
)}
|
||||||
</td>
|
</DoubleBezelCard>
|
||||||
</tr>
|
</Box>
|
||||||
))}
|
)
|
||||||
</tbody>
|
}
|
||||||
</Box>
|
|
||||||
</Box>
|
function RewardsUnavailablePanel({ reason }: { reason: string }): React.ReactElement {
|
||||||
</Box>
|
return (
|
||||||
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
{/* Monthly Settlement & Tax Withholding Payout Ledger */}
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '14px', fontWeight: 600, color: C.orange400, mb: 1 }}>
|
||||||
<Box sx={{ ...panelSx, p: { xs: 2, md: 3 } }}>
|
Rewarded 토큰 지급 통계를 불러올 수 없습니다.
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
</Typography>
|
||||||
<Box>
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim }}>{reason}</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
</DoubleBezelCard>
|
||||||
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.
|
export default async function AdminAdsPage(): Promise<React.ReactElement> {
|
||||||
</Typography>
|
await requireManager()
|
||||||
</Box>
|
|
||||||
<TactileBadge mono tone="success">TAX WITHHOLDING AUTO-CALCULATED</TactileBadge>
|
let stats: AdRewardStats | null = null
|
||||||
</Box>
|
let statsError: string | null = null
|
||||||
|
if (!isSupabaseAdminConfigured()) {
|
||||||
<Box sx={{ overflowX: 'auto' }}>
|
statsError =
|
||||||
<Box component="table" sx={tableSx}>
|
'SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 ad_reward_claims 데이터에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다.'
|
||||||
<thead>
|
} else {
|
||||||
<tr>
|
try {
|
||||||
<th style={{ textAlign: 'left' }}>Settlement ID</th>
|
stats = await fetchAdRewardStats()
|
||||||
<th style={{ textAlign: 'left' }}>Cycle Month</th>
|
} catch (error) {
|
||||||
<th style={{ textAlign: 'left' }}>Network Source</th>
|
statsError = error instanceof Error ? error.message : 'Unknown ad reward stats error'
|
||||||
<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>
|
return (
|
||||||
<th style={{ textAlign: 'center' }}>Payout Status</th>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
</tr>
|
<HeaderBar />
|
||||||
</thead>
|
{stats && <RewardKpis stats={stats} />}
|
||||||
<tbody>
|
<MediationRosterPanel />
|
||||||
{settlements.map((s) => (
|
{stats ? <RewardBreakdownPanel stats={stats} /> : <RewardsUnavailablePanel reason={statsError ?? ''} />}
|
||||||
<tr key={s.id}>
|
</Box>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,13 @@
|
||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Typography } from '@mui/material'
|
||||||
import { C, FONT_SANS, FONT_MONO, panelSx, statusBadgeSx } from '@/lib/console-theme'
|
import { C, FONT_SANS, FONT_MONO, panelSx, statusBadgeSx } from '@/lib/console-theme'
|
||||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
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 { requireManager } from '@/lib/admin-guard'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
import { AuditDiffViewer } from '@/components/audit-diff-viewer'
|
import { AuditDiffViewer } from '@/components/audit-diff-viewer'
|
||||||
|
import { sanitizeAuditSnapshot } from '@/lib/audit-sanitize'
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
|
|
@ -15,40 +18,36 @@ interface PageProps {
|
||||||
|
|
||||||
export default async function AuditLogDetailPage({ params }: PageProps): Promise<React.ReactElement> {
|
export default async function AuditLogDetailPage({ params }: PageProps): Promise<React.ReactElement> {
|
||||||
await requireManager()
|
await requireManager()
|
||||||
const { id } = await params
|
if (!isSupabaseAdminConfigured()) {
|
||||||
const supabase = await getSupabaseServerClient()
|
return (
|
||||||
|
<UnavailableAdminPanel
|
||||||
const { data: log } = await supabase
|
title="Audit Log Detail"
|
||||||
.from('audit_log')
|
capability="Admin actions, changes, accountability"
|
||||||
.select('*')
|
reason="SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 Supabase 관리 데이터에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다."
|
||||||
.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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -82,7 +81,7 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontSize: '20px',
|
fontSize: '20px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
color: C.bright,
|
color: C.bright,
|
||||||
letterSpacing: '-0.02em',
|
letterSpacing: '-0.02em',
|
||||||
m: 0,
|
m: 0,
|
||||||
|
|
@ -99,7 +98,7 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise
|
||||||
← Back to Audit Ledger
|
← Back to Audit Ledger
|
||||||
</Link>
|
</Link>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||||
SHA-256 Checksum Verified
|
Persisted audit record
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</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 }}>
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1fr 1fr' }, gap: 3 }}>
|
||||||
{/* Details Card */}
|
{/* Details Card */}
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<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
|
Transaction Metadata
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, fontFamily: FONT_SANS, fontSize: '13px' }}>
|
<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 */}
|
{/* Memo Card */}
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<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
|
Administrative Intent & Reason
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box
|
||||||
|
|
@ -148,7 +147,7 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise
|
||||||
{/* Visual JSON State Diff */}
|
{/* Visual JSON State Diff */}
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
<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)
|
Entity State Transition Diff (Before vs After)
|
||||||
</Typography>
|
</Typography>
|
||||||
<TactileBadge tone="success" mono>
|
<TactileBadge tone="success" mono>
|
||||||
|
|
@ -157,8 +156,8 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<AuditDiffViewer
|
<AuditDiffViewer
|
||||||
beforeData={typedLog.before_data as Record<string, unknown> | null}
|
beforeData={sanitizeAuditSnapshot(typedLog.before_data)}
|
||||||
afterData={typedLog.after_data as Record<string, unknown> | null}
|
afterData={sanitizeAuditSnapshot(typedLog.after_data)}
|
||||||
/>
|
/>
|
||||||
</DoubleBezelCard>
|
</DoubleBezelCard>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
import { Box, Typography, Button } from '@mui/material'
|
import { Box, Typography, Button } from '@mui/material'
|
||||||
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, filterBtnSx, statusBadgeSx } from '@/lib/console-theme'
|
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, filterBtnSx, statusBadgeSx } from '@/lib/console-theme'
|
||||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
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 { requireManager } from '@/lib/admin-guard'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
|
@ -14,36 +15,40 @@ interface PageProps {
|
||||||
|
|
||||||
export default async function AuditLogPage({ searchParams }: PageProps): Promise<React.ReactElement> {
|
export default async function AuditLogPage({ searchParams }: PageProps): Promise<React.ReactElement> {
|
||||||
await requireManager()
|
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 params = await searchParams
|
||||||
const targetTypeFilter = params.target_type ?? 'all'
|
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 limit = 20
|
||||||
const from = (page - 1) * limit
|
const from = (page - 1) * limit
|
||||||
const to = from + limit - 1
|
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') {
|
if (targetTypeFilter !== 'all') {
|
||||||
query = query.eq('target_type', targetTypeFilter)
|
query = query.eq('target_type', targetTypeFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: rawLogs } = await query
|
const { data: rawLogs, error: logsError } = await query
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.range(from, to)
|
.range(from, to)
|
||||||
|
if (logsError) throw new Error(`Supabase audit log failed: ${logsError.message}`)
|
||||||
|
|
||||||
let logs = (rawLogs ?? []) as Array<Record<string, unknown>>
|
const 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' },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -77,7 +82,7 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontSize: '20px',
|
fontSize: '20px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
color: C.bright,
|
color: C.bright,
|
||||||
letterSpacing: '-0.02em',
|
letterSpacing: '-0.02em',
|
||||||
m: 0,
|
m: 0,
|
||||||
|
|
@ -86,7 +91,7 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
|
||||||
Security Audit Log & Event Ledger
|
Security Audit Log & Event Ledger
|
||||||
</Typography>
|
</Typography>
|
||||||
<TactileBadge tone="mono" mono>
|
<TactileBadge tone="mono" mono>
|
||||||
IMMUTABLE AUDIT TRAIL
|
PERSISTED AUDIT TRAIL
|
||||||
</TactileBadge>
|
</TactileBadge>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography
|
<Typography
|
||||||
|
|
@ -98,13 +103,13 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
|
||||||
mt: 0.25,
|
mt: 0.25,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
ADMIN ACTION AUDIT • TIER MODIFICATIONS • ENDPOINT CONFIGURATION TRACE
|
Admin action audit, role and subscription modifications
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
<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' }}>
|
<Link key={t} href={`/audit-log?target_type=${t}`} style={{ textDecoration: 'none' }}>
|
||||||
<Box sx={filterBtnSx(targetTypeFilter === t)}>
|
<Box sx={filterBtnSx(targetTypeFilter === t)}>
|
||||||
{t.toUpperCase()}
|
{t.toUpperCase()}
|
||||||
|
|
@ -117,11 +122,11 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
|
||||||
{/* Main Table Card */}
|
{/* Main Table Card */}
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
|
<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
|
Chronological Security Log Entries
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||||
AUTO-SIGN SHA-256 VERIFIED
|
DATABASE AUDIT RECORDS
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|
@ -138,7 +143,13 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="tbody">
|
<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="tr" key={log.id as number}>
|
||||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
|
||||||
{new Date(log.created_at as string).toLocaleString()}
|
{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
|
children: React.ReactNode
|
||||||
}): Promise<React.ReactElement> {
|
}): Promise<React.ReactElement> {
|
||||||
await requireManager()
|
const admin = await requireManager()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
|
@ -27,7 +27,7 @@ export default async function AdminLayout({
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AdminSidebar />
|
<AdminSidebar identity={{ email: admin.email ?? admin.id, role: admin.role }} />
|
||||||
<Box
|
<Box
|
||||||
component="main"
|
component="main"
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -56,4 +56,3 @@ export default async function AdminLayout({
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,18 +24,22 @@ import {
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx, primaryButtonSx } from '@/lib/console-theme'
|
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx, primaryButtonSx } from '@/lib/console-theme'
|
||||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||||
import {
|
import type {
|
||||||
fetchModelEndpoints,
|
ModelEndpoint,
|
||||||
fetchSttEndpoints,
|
SttProviderEndpoint,
|
||||||
createSttEndpoint,
|
STTProviderCategory,
|
||||||
updateSttEndpoint,
|
|
||||||
deleteSttEndpoint,
|
|
||||||
setDefaultSttEndpoint,
|
|
||||||
testSttEndpoint,
|
|
||||||
type ModelEndpoint,
|
|
||||||
type SttProviderEndpoint,
|
|
||||||
type STTProviderCategory,
|
|
||||||
} from '@/lib/api-server'
|
} from '@/lib/api-server'
|
||||||
|
import {
|
||||||
|
createModelEndpointClient,
|
||||||
|
createSttEndpointClient,
|
||||||
|
deleteModelEndpointClient,
|
||||||
|
deleteSttEndpointClient,
|
||||||
|
fetchModelEndpointsClient,
|
||||||
|
fetchSttEndpointsClient,
|
||||||
|
setDefaultSttEndpointClient,
|
||||||
|
testSttEndpointClient,
|
||||||
|
updateSttEndpointClient,
|
||||||
|
} from '@/lib/backend-admin-client'
|
||||||
|
|
||||||
const PRESET_LLM_MODELS = [
|
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' },
|
{ 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
|
providerType: STTProviderCategory
|
||||||
endpointUrl: string
|
endpointUrl: string
|
||||||
modelId: string
|
modelId: string
|
||||||
method: string
|
method: 'multipart' | 'binary-stream' | 'json-base64' | 'custom-rest'
|
||||||
costPerMinute: number
|
costPerMinute: number
|
||||||
language: string
|
language: string
|
||||||
prompt?: string
|
prompt?: string
|
||||||
description: 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: 'Groq Whisper LPU Turbo (Ultra Fast)',
|
{ 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 모델, 표준 고품질 다국어 인식' },
|
||||||
providerType: 'groq',
|
{ 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 스마트 구두점, 실시간 스트리밍 최적화 및 고정밀 전사' },
|
||||||
endpointUrl: 'https://api.groq.com/openai/v1/audio/transcriptions',
|
{ 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 기반 다국어 및 문맥 인식 오디오 전사' },
|
||||||
modelId: 'whisper-large-v3-turbo',
|
{ name: 'AssemblyAI Universal-2', providerType: 'assemblyai', endpointUrl: 'https://api.assemblyai.com/v2/transcript', modelId: 'best', method: 'multipart', costPerMinute: 0.0025, language: 'ko', description: '문맥 인식 음향 모델 및 자동 단락 구분 STT' },
|
||||||
method: 'multipart',
|
{ 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 엔터프라이즈 음성 인식' },
|
||||||
costPerMinute: 0.0005,
|
{ 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 사이드카 (완전 무료/오프라인)' },
|
||||||
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 {
|
export default function ServiceModelsPage(): React.ReactElement {
|
||||||
|
|
@ -142,39 +83,42 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
|
|
||||||
// STT Form State
|
// STT Form State
|
||||||
const [sttName, setSttName] = useState('')
|
const [sttName, setSttName] = useState('')
|
||||||
const [sttProviderType, setSttProviderType] = useState<STTProviderCategory>('groq')
|
const [sttProviderType, setSttProviderType] = useState<STTProviderCategory>('custom')
|
||||||
const [sttEndpointUrl, setSttEndpointUrl] = useState('')
|
const [sttEndpointUrl, setSttEndpointUrl] = useState('')
|
||||||
const [sttApiKey, setSttApiKey] = 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 [sttMethod, setSttMethod] = useState<'multipart' | 'binary-stream' | 'json-base64' | 'custom-rest'>('multipart')
|
||||||
const [sttLanguage, setSttLanguage] = useState('ko')
|
const [sttLanguage, setSttLanguage] = useState('ko')
|
||||||
const [sttPrompt, setSttPrompt] = useState('')
|
const [sttPrompt, setSttPrompt] = useState('')
|
||||||
const [sttCostPerMinute, setSttCostPerMinute] = useState('0.000500')
|
const [sttCostPerMinute, setSttCostPerMinute] = useState('0')
|
||||||
const [sttFallbackPriority, setSttFallbackPriority] = useState(1)
|
const [sttFallbackPriority, setSttFallbackPriority] = useState(1)
|
||||||
const [sttIsDefault, setSttIsDefault] = useState(false)
|
const [sttIsDefault, setSttIsDefault] = useState(false)
|
||||||
|
const [sttMemo, setSttMemo] = useState('')
|
||||||
|
|
||||||
// LLM Endpoints State
|
// LLM Endpoints State
|
||||||
const [llmEndpoints, setLlmEndpoints] = useState<ModelEndpoint[]>([])
|
const [llmEndpoints, setLlmEndpoints] = useState<ModelEndpoint[]>([])
|
||||||
const [llmModalOpen, setLlmModalOpen] = useState(false)
|
const [llmModalOpen, setLlmModalOpen] = useState(false)
|
||||||
const [llmLoading, setLlmLoading] = useState(false)
|
const [llmLoading, setLlmLoading] = useState(false)
|
||||||
const [llmPingStatus, setLlmPingStatus] = useState<Record<string, string>>({})
|
const [dataError, setDataError] = useState<string | null>(null)
|
||||||
|
|
||||||
// LLM Form State
|
// LLM Form State
|
||||||
const [llmModelId, setLlmModelId] = useState('')
|
const [llmModelId, setLlmModelId] = useState('')
|
||||||
const [llmModelName, setLlmModelName] = useState('')
|
const [llmModelName, setLlmModelName] = useState('')
|
||||||
const [llmProvider, setLlmProvider] = useState<string>('OpenAI')
|
const [llmProvider, setLlmProvider] = useState<string>('Custom')
|
||||||
const [llmEndpointUrl, setLlmEndpointUrl] = useState('')
|
const [llmEndpointUrl, setLlmEndpointUrl] = useState('')
|
||||||
const [llmApiKey, setLlmApiKey] = useState('')
|
const [llmApiKey, setLlmApiKey] = useState('')
|
||||||
const [llmPromptCost, setLlmPromptCost] = useState('0.000150')
|
const [llmPromptCost, setLlmPromptCost] = useState('0')
|
||||||
const [llmCompletionCost, setLlmCompletionCost] = useState('0.000600')
|
const [llmCompletionCost, setLlmCompletionCost] = useState('0')
|
||||||
|
const [llmMemo, setLlmMemo] = useState('')
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
|
setDataError(null)
|
||||||
try {
|
try {
|
||||||
const [sttData, llmData] = await Promise.all([fetchSttEndpoints(), fetchModelEndpoints()])
|
const [sttData, llmData] = await Promise.all([fetchSttEndpointsClient(), fetchModelEndpointsClient()])
|
||||||
setSttEndpoints(sttData)
|
setSttEndpoints(sttData)
|
||||||
setLlmEndpoints(llmData)
|
setLlmEndpoints(llmData)
|
||||||
} catch {
|
} catch (error) {
|
||||||
// Fallbacks handled in fetch functions
|
setDataError(error instanceof Error ? error.message : '관리자 백엔드에서 엔드포인트를 불러오지 못했습니다.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,34 +128,21 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
|
|
||||||
// ── STT Handlers ──────────────────────────────────────────────────────────
|
// ── 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 = () => {
|
const openAddSttModal = () => {
|
||||||
setSttEditingId(null)
|
setSttEditingId(null)
|
||||||
setSttName('')
|
setSttName('')
|
||||||
setSttProviderType('groq')
|
setSttProviderType('custom')
|
||||||
setSttEndpointUrl('https://api.groq.com/openai/v1/audio/transcriptions')
|
setSttEndpointUrl('')
|
||||||
setSttApiKey('')
|
setSttApiKey('')
|
||||||
setSttModelId('whisper-large-v3-turbo')
|
setSttModelId('')
|
||||||
setSttMethod('multipart')
|
setSttMethod('multipart')
|
||||||
setSttLanguage('ko')
|
setSttLanguage('ko')
|
||||||
setSttPrompt('')
|
setSttPrompt('')
|
||||||
setSttCostPerMinute('0.000500')
|
setSttCostPerMinute('0')
|
||||||
setSttFallbackPriority(sttEndpoints.length + 1)
|
setSttFallbackPriority(sttEndpoints.length + 1)
|
||||||
setSttIsDefault(sttEndpoints.length === 0)
|
setSttIsDefault(sttEndpoints.length === 0)
|
||||||
setSttTestModalResult(null)
|
setSttTestModalResult(null)
|
||||||
|
setSttMemo('')
|
||||||
setSttModalOpen(true)
|
setSttModalOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -252,12 +183,13 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
isDefault: sttIsDefault,
|
isDefault: sttIsDefault,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
fallbackPriority: sttFallbackPriority,
|
fallbackPriority: sttFallbackPriority,
|
||||||
|
memo: sttMemo.trim(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sttEditingId) {
|
if (sttEditingId) {
|
||||||
await updateSttEndpoint(sttEditingId, payload)
|
await updateSttEndpointClient(sttEditingId, payload)
|
||||||
} else {
|
} else {
|
||||||
await createSttEndpoint(payload)
|
await createSttEndpointClient(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadData()
|
await loadData()
|
||||||
|
|
@ -270,8 +202,10 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSetDefaultStt = async (id: number) => {
|
const handleSetDefaultStt = async (id: number) => {
|
||||||
|
const memo = prompt('기본 STT 공급자 변경 사유를 입력하세요.')?.trim() ?? ''
|
||||||
|
if (memo.length < 3) return
|
||||||
try {
|
try {
|
||||||
await setDefaultSttEndpoint(id)
|
await setDefaultSttEndpointClient(id, memo)
|
||||||
setSttEndpoints((prev) =>
|
setSttEndpoints((prev) =>
|
||||||
prev.map((ep) => ({
|
prev.map((ep) => ({
|
||||||
...ep,
|
...ep,
|
||||||
|
|
@ -285,11 +219,17 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
|
|
||||||
const handlePingStt = async (id: number) => {
|
const handlePingStt = async (id: number) => {
|
||||||
setSttPingStatus((prev) => ({ ...prev, [id]: 'Testing...' }))
|
setSttPingStatus((prev) => ({ ...prev, [id]: 'Testing...' }))
|
||||||
const result = await testSttEndpoint(id)
|
try {
|
||||||
if (result.success) {
|
const result = await testSttEndpointClient(id)
|
||||||
setSttPingStatus((prev) => ({ ...prev, [id]: `⚡ OK • ${result.latencyMs}ms` }))
|
setSttPingStatus((prev) => ({
|
||||||
} else {
|
...prev,
|
||||||
setSttPingStatus((prev) => ({ ...prev, [id]: `❌ Failed` }))
|
[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)
|
setTestingInModal(true)
|
||||||
setSttTestModalResult(null)
|
setSttTestModalResult(null)
|
||||||
try {
|
try {
|
||||||
const result = await testSttEndpoint(sttEditingId ?? 0, sttApiKey, sttEndpointUrl)
|
const result = await testSttEndpointClient(sttEditingId ?? 0, sttApiKey, sttEndpointUrl)
|
||||||
setSttTestModalResult({
|
setSttTestModalResult({
|
||||||
success: result.success,
|
success: result.success,
|
||||||
message: result.message,
|
message: result.message,
|
||||||
|
|
@ -316,8 +256,10 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
|
|
||||||
const handleDeleteStt = async (id: number) => {
|
const handleDeleteStt = async (id: number) => {
|
||||||
if (!confirm('이 STT 프로바이더 엔드포인트를 삭제하시겠습니까?')) return
|
if (!confirm('이 STT 프로바이더 엔드포인트를 삭제하시겠습니까?')) return
|
||||||
|
const memo = prompt('삭제 사유를 입력하세요.')?.trim() ?? ''
|
||||||
|
if (memo.length < 3) return
|
||||||
try {
|
try {
|
||||||
await deleteSttEndpoint(id)
|
await deleteSttEndpointClient(id, memo)
|
||||||
setSttEndpoints((prev) => prev.filter((ep) => ep.id !== id))
|
setSttEndpoints((prev) => prev.filter((ep) => ep.id !== id))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Error: ' + (err instanceof Error ? err.message : String(err)))
|
alert('Error: ' + (err instanceof Error ? err.message : String(err)))
|
||||||
|
|
@ -326,15 +268,16 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
|
|
||||||
// ── LLM Handlers ──────────────────────────────────────────────────────────
|
// ── LLM Handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const applyLlmPreset = (presetKey: string) => {
|
const openAddLlmModal = () => {
|
||||||
const p = PRESET_LLM_MODELS.find((m) => m.modelId === presetKey)
|
setLlmModelId('')
|
||||||
if (!p) return
|
setLlmModelName('')
|
||||||
setLlmModelId(p.modelId)
|
setLlmProvider('Custom')
|
||||||
setLlmModelName(p.modelName)
|
setLlmEndpointUrl('')
|
||||||
setLlmProvider(p.provider)
|
setLlmApiKey('')
|
||||||
setLlmEndpointUrl(p.endpointUrl)
|
setLlmPromptCost('0')
|
||||||
setLlmPromptCost(p.promptCost)
|
setLlmCompletionCost('0')
|
||||||
setLlmCompletionCost(p.completionCost)
|
setLlmMemo('')
|
||||||
|
setLlmModalOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddLlmEndpoint = async (e: React.FormEvent) => {
|
const handleAddLlmEndpoint = async (e: React.FormEvent) => {
|
||||||
|
|
@ -342,26 +285,23 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
setLlmLoading(true)
|
setLlmLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newEp: ModelEndpoint = {
|
await createModelEndpointClient({
|
||||||
id: Date.now(),
|
|
||||||
modelId: llmModelId,
|
modelId: llmModelId,
|
||||||
modelName: llmModelName,
|
modelName: llmModelName,
|
||||||
provider: llmProvider as ModelEndpoint['provider'],
|
provider: llmProvider,
|
||||||
endpointUrl: llmEndpointUrl,
|
endpointUrl: llmEndpointUrl,
|
||||||
apiKey: llmApiKey ? '••••••••' : '',
|
apiKey: llmApiKey,
|
||||||
costPer1kPromptTokens: parseFloat(llmPromptCost),
|
costPer1kPromptTokens: parseFloat(llmPromptCost),
|
||||||
costPer1kCompletionTokens: parseFloat(llmCompletionCost),
|
costPer1kCompletionTokens: parseFloat(llmCompletionCost),
|
||||||
latencyMs: 150,
|
memo: llmMemo.trim(),
|
||||||
isActive: true,
|
})
|
||||||
isDefault: false,
|
await loadData()
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
setLlmEndpoints((prev) => [...prev, newEp])
|
|
||||||
setLlmModalOpen(false)
|
setLlmModalOpen(false)
|
||||||
setLlmModelId('')
|
setLlmModelId('')
|
||||||
setLlmModelName('')
|
setLlmModelName('')
|
||||||
setLlmEndpointUrl('')
|
setLlmEndpointUrl('')
|
||||||
setLlmApiKey('')
|
setLlmApiKey('')
|
||||||
|
setLlmMemo('')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Error: ' + (err instanceof Error ? err.message : String(err)))
|
alert('Error: ' + (err instanceof Error ? err.message : String(err)))
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -369,12 +309,16 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePingLlm = (_id: number, modelKey: string) => {
|
const handleDeleteLlm = async (id: number) => {
|
||||||
setLlmPingStatus((prev) => ({ ...prev, [modelKey]: 'Testing...' }))
|
if (!confirm('이 LLM 엔드포인트를 삭제하시겠습니까?')) return
|
||||||
setTimeout(() => {
|
const memo = prompt('삭제 사유를 입력하세요.')?.trim() ?? ''
|
||||||
const lat = Math.floor(Math.random() * 80 + 40)
|
if (memo.length < 3) return
|
||||||
setLlmPingStatus((prev) => ({ ...prev, [modelKey]: `OK • ${lat}ms` }))
|
try {
|
||||||
}, 500)
|
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]
|
const defaultStt = sttEndpoints.find((e) => e.isDefault) || sttEndpoints[0]
|
||||||
|
|
@ -411,7 +355,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontSize: '20px',
|
fontSize: '20px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
color: C.bright,
|
color: C.bright,
|
||||||
letterSpacing: '-0.02em',
|
letterSpacing: '-0.02em',
|
||||||
m: 0,
|
m: 0,
|
||||||
|
|
@ -432,7 +376,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
mt: 0.25,
|
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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -443,13 +387,19 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
+ Add STT Provider Endpoint
|
+ Add STT Provider Endpoint
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="contained" onClick={() => setLlmModalOpen(true)} sx={primaryButtonSx}>
|
<Button variant="contained" onClick={openAddLlmModal} sx={primaryButtonSx}>
|
||||||
+ Add LLM Model Endpoint
|
+ Add LLM Model Endpoint
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{dataError && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
{dataError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tabs Navigation */}
|
{/* Tabs Navigation */}
|
||||||
<Box sx={{ borderBottom: `1px solid ${C.border}`, mb: 3 }}>
|
<Box sx={{ borderBottom: `1px solid ${C.border}`, mb: 3 }}>
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|
@ -469,7 +419,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
color: activeTab === 'stt' ? C.bright : C.dim,
|
color: activeTab === 'stt' ? C.bright : C.dim,
|
||||||
textTransform: 'none',
|
textTransform: 'none',
|
||||||
px: 3,
|
px: 3,
|
||||||
|
|
@ -483,7 +433,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
color: activeTab === 'llm' ? C.bright : C.dim,
|
color: activeTab === 'llm' ? C.bright : C.dim,
|
||||||
textTransform: 'none',
|
textTransform: 'none',
|
||||||
px: 3,
|
px: 3,
|
||||||
|
|
@ -519,7 +469,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<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}
|
Active Default Cloud STT: {defaultStt.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box component="span" sx={statusBadgeSx('green')}>
|
<Box component="span" sx={statusBadgeSx('green')}>
|
||||||
|
|
@ -545,7 +495,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
|
||||||
<Box>
|
<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
|
Configured Cloud Transcription Providers
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim }}>
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim }}>
|
||||||
|
|
@ -581,7 +531,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
) : (
|
) : (
|
||||||
sttEndpoints.map((ep) => (
|
sttEndpoints.map((ep) => (
|
||||||
<Box component="tr" key={ep.id} sx={{ bgcolor: ep.isDefault ? 'rgba(59, 130, 246, 0.05)' : 'transparent' }}>
|
<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}
|
#{ep.fallbackPriority}
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="td" sx={{ fontWeight: 600, color: C.bright }}>
|
<Box component="td" sx={{ fontWeight: 600, color: C.bright }}>
|
||||||
|
|
@ -606,7 +556,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="td">
|
<Box component="td">
|
||||||
{ep.isDefault ? (
|
{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
|
⭐ DEFAULT
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -691,7 +641,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
{activeTab === 'llm' && (
|
{activeTab === 'llm' && (
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
|
<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
|
Active AI Reasoning & Action Endpoints
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
<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 }}>
|
<Box sx={{ display: 'inline-flex', gap: 1 }}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
color="error"
|
||||||
onClick={() => handlePingLlm(ep.id, ep.modelId)}
|
onClick={() => handleDeleteLlm(ep.id)}
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: FONT_MONO,
|
fontFamily: FONT_MONO,
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
|
|
@ -769,7 +719,7 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
'&:hover': { bgcolor: 'rgba(59, 130, 246, 0.15)', borderColor: C.accentLight },
|
'&:hover': { bgcolor: 'rgba(59, 130, 246, 0.15)', borderColor: C.accentLight },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{llmPingStatus[ep.modelId] || '⚡ Ping'}
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</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'}
|
{sttEditingId ? 'Edit STT Provider Endpoint' : 'Add Cloud STT Provider Endpoint'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<Box component="form" onSubmit={handleSaveSttEndpoint}>
|
<Box component="form" onSubmit={handleSaveSttEndpoint}>
|
||||||
<DialogContent sx={{ px: 3, py: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
<DialogContent sx={{ px: 3, py: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
{/* Quick Preset Selector */}
|
{sttEditingId === null && (
|
||||||
<FormControl fullWidth size="small">
|
<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
|
<Select
|
||||||
label="Load Preset Template"
|
value=""
|
||||||
defaultValue=""
|
label="⚡ Load Preset Template (optional)"
|
||||||
onChange={(e) => applySttPreset(e.target.value)}
|
onChange={(e) => {
|
||||||
sx={{
|
const preset = PRESET_STT_PROVIDERS.find((p) => p.name === e.target.value)
|
||||||
bgcolor: 'rgba(10, 17, 31, 0.7)',
|
if (!preset) return
|
||||||
color: C.bright,
|
setSttName(preset.name)
|
||||||
borderRadius: '10px',
|
setSttProviderType(preset.providerType)
|
||||||
fontFamily: FONT_SANS,
|
setSttEndpointUrl(preset.endpointUrl)
|
||||||
'& fieldset': { borderColor: C.border },
|
setSttModelId(preset.modelId)
|
||||||
'&:hover fieldset': { borderColor: C.borderHl },
|
setSttMethod(preset.method)
|
||||||
}}
|
setSttLanguage(preset.language)
|
||||||
>
|
setSttCostPerMinute(String(preset.costPerMinute))
|
||||||
{PRESET_STT_PROVIDERS.map((p) => (
|
if (preset.prompt) setSttPrompt(preset.prompt)
|
||||||
<MenuItem key={p.name} value={p.name} sx={{ fontFamily: FONT_SANS, fontSize: '13px' }}>
|
}}
|
||||||
{p.name} ({p.providerType.toUpperCase()}) — ${p.costPerMinute}/min
|
sx={{
|
||||||
</MenuItem>
|
bgcolor: 'rgba(10, 17, 31, 0.7)',
|
||||||
))}
|
color: C.bright,
|
||||||
</Select>
|
borderRadius: '10px',
|
||||||
</FormControl>
|
'& 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 }}>
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1.2fr 0.8fr' }, gap: 2 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Provider Name"
|
label="Provider Name"
|
||||||
|
|
@ -984,6 +943,16 @@ export default function ServiceModelsPage(): React.ReactElement {
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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 */}
|
{/* 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={{ 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' }}>
|
<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' }}>
|
<Button onClick={() => setSttModalOpen(false)} sx={{ color: C.dim, textTransform: 'none' }}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</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'}
|
{sttLoading ? 'Saving...' : 'Save STT Provider'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</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
|
Add Reasoning Model Endpoint
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<Box component="form" onSubmit={handleAddLlmEndpoint}>
|
<Box component="form" onSubmit={handleAddLlmEndpoint}>
|
||||||
<DialogContent sx={{ px: 3, py: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
<DialogContent sx={{ px: 3, py: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
<FormControl fullWidth size="small">
|
<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
|
<Select
|
||||||
label="Load Preset Template"
|
value=""
|
||||||
defaultValue=""
|
label="⚡ Load Preset Template (optional)"
|
||||||
onChange={(e) => applyLlmPreset(e.target.value)}
|
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={{
|
sx={{
|
||||||
bgcolor: 'rgba(10, 17, 31, 0.7)',
|
bgcolor: 'rgba(10, 17, 31, 0.7)',
|
||||||
color: C.bright,
|
color: C.bright,
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
fontFamily: FONT_SANS,
|
|
||||||
'& fieldset': { borderColor: C.border },
|
'& fieldset': { borderColor: C.border },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PRESET_LLM_MODELS.map((p) => (
|
{PRESET_LLM_MODELS.map((preset) => (
|
||||||
<MenuItem key={p.modelId} value={p.modelId} sx={{ fontFamily: FONT_SANS, fontSize: '13px' }}>
|
<MenuItem key={preset.modelId} value={preset.modelId}>
|
||||||
{p.modelName} ({p.provider})
|
{preset.modelName}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Model ID"
|
label="Model ID"
|
||||||
value={llmModelId}
|
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 } }}
|
sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'rgba(10, 17, 31, 0.7)', color: C.bright, borderRadius: '10px', fontFamily: FONT_MONO } }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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>
|
</DialogContent>
|
||||||
<DialogActions sx={{ px: 3, pb: 3, pt: 1, borderTop: `1px solid ${C.border}` }}>
|
<DialogActions sx={{ px: 3, pb: 3, pt: 1, borderTop: `1px solid ${C.border}` }}>
|
||||||
<Button onClick={() => setLlmModalOpen(false)} sx={{ color: C.dim, textTransform: 'none' }}>
|
<Button onClick={() => setLlmModalOpen(false)} sx={{ color: C.dim, textTransform: 'none' }}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</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'}
|
{llmLoading ? 'Saving...' : 'Save Endpoint'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,61 @@
|
||||||
|
|
||||||
import { Box, Typography, Button } from '@mui/material'
|
import { Box, Typography, Button } from '@mui/material'
|
||||||
import { fetchServerStats } from '@/lib/api-server'
|
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 { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
|
||||||
import { DoubleBezelCard, StatRing, TactileBadge } from '@d3ro/ui/components/ds'
|
import { DoubleBezelCard, StatRing, TactileBadge } from '@d3ro/ui/components/ds'
|
||||||
import { DashboardSimulator } from '@/components/dashboard-simulator'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
||||||
const stats = await fetchServerStats()
|
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 = [
|
const bentoCards = [
|
||||||
{
|
{
|
||||||
title: 'Annual Recurring Revenue (ARR)',
|
title: 'Backend Uptime',
|
||||||
value: `$${stats.arrUsd.toLocaleString()}`,
|
value: `${Math.floor(stats.serverUptimeSeconds / 3600).toLocaleString()}h`,
|
||||||
subtext: `MRR: $${stats.mrrUsd.toLocaleString()} • +18.4% MoM Growth`,
|
subtext: 'Measured by the active .NET API process',
|
||||||
color: 'purple' as const,
|
color: 'purple' as const,
|
||||||
badge: 'REVENUE',
|
badge: 'RUNTIME',
|
||||||
badgeColor: 'purple' as const,
|
badgeColor: 'purple' as const,
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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`,
|
value: `${stats.activeUsersToday.toLocaleString()} Active`,
|
||||||
subtext: `${stats.totalUsers.toLocaleString()} Total Users • 18 Realtime Streams`,
|
subtext: `${stats.totalUsers.toLocaleString()} administrator accounts`,
|
||||||
color: 'blue' as const,
|
color: 'blue' as const,
|
||||||
badge: 'VOICE STREAMS',
|
badge: 'AUTH',
|
||||||
badgeColor: 'blue' as const,
|
badgeColor: 'blue' as const,
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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',
|
title: 'Recorded Backend Errors',
|
||||||
value: `${stats.pipelines.meetingIntelligence.speakerAccuracyPercent}%`,
|
value: stats.errorCount.toLocaleString(),
|
||||||
subtext: `${stats.pipelines.meetingIntelligence.templatesGeneratedToday} Meeting Docs • 42 Mindmaps`,
|
subtext: 'Persisted server error log entries',
|
||||||
color: 'orange' as const,
|
color: 'orange' as const,
|
||||||
badge: 'PHASE 15.5',
|
badge: 'ERROR LOG',
|
||||||
badgeColor: 'orange' as const,
|
badgeColor: 'orange' as const,
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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={{
|
sx={{
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontSize: '20px',
|
fontSize: '20px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
color: C.bright,
|
color: C.bright,
|
||||||
letterSpacing: '-0.02em',
|
letterSpacing: '-0.02em',
|
||||||
m: 0,
|
m: 0,
|
||||||
|
|
@ -107,7 +147,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
||||||
Unified Dashboard Overview
|
Unified Dashboard Overview
|
||||||
</Typography>
|
</Typography>
|
||||||
<TactileBadge ledColor="green" ledPulse tone="success" mono>
|
<TactileBadge ledColor="green" ledPulse tone="success" mono>
|
||||||
ONLINE • v0.2.1-alpha
|
BACKEND CONNECTED
|
||||||
</TactileBadge>
|
</TactileBadge>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography
|
<Typography
|
||||||
|
|
@ -119,7 +159,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
||||||
mt: 0.25,
|
mt: 0.25,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
REALTIME AI TELEMETRY • ARR & SUBSCRIPTION METRICS • PIPELINE HEALTH
|
Backend runtime counters, error ledger, reported node health
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -149,6 +189,70 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3.5 }}>
|
<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 */}
|
{/* Executive Bento Grid */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -178,10 +282,9 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
color: C.dim,
|
color: C.dim,
|
||||||
textTransform: 'uppercase',
|
letterSpacing: '0.04em',
|
||||||
letterSpacing: '0.06em',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{card.title}
|
{card.title}
|
||||||
|
|
@ -191,7 +294,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: FONT_MONO,
|
fontFamily: FONT_MONO,
|
||||||
fontSize: '26px',
|
fontSize: '26px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
color: C.bright,
|
color: C.bright,
|
||||||
my: 0.75,
|
my: 0.75,
|
||||||
}}
|
}}
|
||||||
|
|
@ -216,15 +319,19 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
|
||||||
<Box>
|
<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
|
System Nodes & Pipeline Topology
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
<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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<TactileBadge tone="success" mono>
|
<TactileBadge tone="mono" mono>
|
||||||
ALL OPERATIONAL
|
{stats.nodes.length === 0
|
||||||
|
? 'UNAVAILABLE'
|
||||||
|
: nonOperationalNodes.length === 0 ? 'ALL REPORTED OPERATIONAL' : 'ATTENTION REQUIRED'}
|
||||||
</TactileBadge>
|
</TactileBadge>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|
@ -235,7 +342,11 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
||||||
gap: 2,
|
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
|
<Box
|
||||||
key={node.id}
|
key={node.id}
|
||||||
sx={{
|
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 }}>
|
<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}
|
{node.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box
|
||||||
|
|
@ -260,8 +371,8 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
bgcolor: '#10b981',
|
bgcolor: node.status === 'operational' ? '#10b981' : node.status === 'degraded' ? '#f59e0b' : '#ef4444',
|
||||||
boxShadow: '0 0 8px #10b981',
|
boxShadow: `0 0 8px ${node.status === 'operational' ? '#10b981' : node.status === 'degraded' ? '#f59e0b' : '#ef4444'}`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -274,8 +385,8 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>
|
||||||
Latency: <strong style={{ color: C.bright }}>{node.latencyMs}ms</strong>
|
Latency: <strong style={{ color: C.bright }}>{node.latencyMs}ms</strong>
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.green400 }}>
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: node.status === 'operational' ? C.green400 : node.status === 'degraded' ? C.orange400 : C.red400 }}>
|
||||||
{node.uptimePercent}% Up
|
{node.uptimePercent}% · {node.status.toUpperCase()}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -283,17 +394,14 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
||||||
</Box>
|
</Box>
|
||||||
</DoubleBezelCard>
|
</DoubleBezelCard>
|
||||||
|
|
||||||
{/* Live Audio & Voice Intelligence Simulator Widget */}
|
|
||||||
<DashboardSimulator />
|
|
||||||
|
|
||||||
{/* Server Operational Telemetry Logs */}
|
{/* Server Operational Telemetry Logs */}
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
<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
|
Operational Telemetry & Server Logs
|
||||||
</Typography>
|
</Typography>
|
||||||
<TactileBadge tone="mono" mono>
|
<TactileBadge tone="mono" mono>
|
||||||
AUTO REFRESH (30s)
|
SERVER SNAPSHOT
|
||||||
</TactileBadge>
|
</TactileBadge>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|
@ -312,7 +420,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
|
||||||
{stats.recentErrors.length === 0 ? (
|
{stats.recentErrors.length === 0 ? (
|
||||||
<Box component="tr">
|
<Box component="tr">
|
||||||
<Box component="td" colSpan={5} sx={{ textAlign: 'center', color: C.green400, py: 3 }}>
|
<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>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,18 @@ import { StatRing, TactileBadge, DoubleBezelCard } from '@d3ro/ui/components/ds'
|
||||||
|
|
||||||
export default async function PipelinesPage(): Promise<React.ReactElement> {
|
export default async function PipelinesPage(): Promise<React.ReactElement> {
|
||||||
const stats = await fetchServerStats()
|
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
|
const { whisper, ollama, realtimeVoice, ragVector, meetingIntelligence } = stats.pipelines
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -42,7 +54,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontSize: '18px',
|
fontSize: '18px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
color: C.bright,
|
color: C.bright,
|
||||||
letterSpacing: '-0.02em',
|
letterSpacing: '-0.02em',
|
||||||
m: 0,
|
m: 0,
|
||||||
|
|
@ -63,7 +75,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
||||||
mt: 0.25,
|
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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -103,7 +115,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
||||||
</svg>
|
</svg>
|
||||||
</StatRing>
|
</StatRing>
|
||||||
<Box>
|
<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
|
Faster-Whisper STT Sidecar
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight }}>
|
<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={{ 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}` }}>
|
<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: '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
|
{whisper.avgLatencyMs}ms
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
<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: '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}
|
{whisper.speedupFactor}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
<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: '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}
|
{whisper.gpuVramUsage}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -156,7 +168,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
||||||
</svg>
|
</svg>
|
||||||
</StatRing>
|
</StatRing>
|
||||||
<Box>
|
<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
|
Bundled Ollama Runtime
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.purple400 }}>
|
<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={{ 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}` }}>
|
<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: '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
|
{ollama.tokensPerSecond} tok/s
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
<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: '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}
|
{ollama.activeContextLimit}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
<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: '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}
|
{ollama.vramAllocated}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -212,7 +224,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
||||||
</svg>
|
</svg>
|
||||||
</StatRing>
|
</StatRing>
|
||||||
<Box>
|
<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
|
GPT-Realtime 2.1 Live Engine
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.orange400 }}>
|
<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={{ 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}` }}>
|
<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: '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
|
{realtimeVoice.activeStreams} Active
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
<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: '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
|
{realtimeVoice.avgAudioRttMs}ms
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
<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: '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}
|
{realtimeVoice.localFallbackRate}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -265,7 +277,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
||||||
</svg>
|
</svg>
|
||||||
</StatRing>
|
</StatRing>
|
||||||
<Box>
|
<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
|
SQLite Vector RAG Engine
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.green400 }}>
|
<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={{ 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}` }}>
|
<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: '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()}
|
{ragVector.indexedDocuments.toLocaleString()}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
<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: '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()}
|
{ragVector.totalVectorChunks.toLocaleString()}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
<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: '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}%
|
{ragVector.topHitRatePercent}%
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -319,7 +331,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
|
||||||
</svg>
|
</svg>
|
||||||
</StatRing>
|
</StatRing>
|
||||||
<Box>
|
<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)
|
Meeting Intelligence & Speaker Diarization (Phase 14~15.5)
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.accentLight }}>
|
<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={{ 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}` }}>
|
<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: '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}%
|
{meetingIntelligence.speakerAccuracyPercent}%
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
|
<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>
|
||||||
<Box sx={{ p: 2, borderRadius: '12px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
<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: '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
|
{meetingIntelligence.activeMeetingSessions} Live
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
|
<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>
|
||||||
<Box sx={{ p: 2, borderRadius: '12px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
<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: '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
|
{meetingIntelligence.templatesGeneratedToday} Docs
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
|
<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>
|
||||||
<Box sx={{ p: 2, borderRadius: '12px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
<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: '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
|
{meetingIntelligence.mindmapsExported} Maps
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
|
<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
|
// 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'
|
export const dynamic = 'force-dynamic'
|
||||||
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'
|
|
||||||
|
|
||||||
interface ReleaseAsset {
|
const PLATFORM_LABEL: Record<ReleaseAssetPlatform, string> = {
|
||||||
id: string
|
windows: 'Windows',
|
||||||
name: string
|
macos: 'macOS',
|
||||||
os: 'Windows' | 'macOS' | 'Linux' | 'Feed'
|
android: 'Android',
|
||||||
version: string
|
linux: 'Linux',
|
||||||
sizeMb: number
|
feed: 'Update Feed',
|
||||||
downloads: number
|
other: 'Other'
|
||||||
sha256: string
|
|
||||||
status: 'active' | 'deprecated' | 'archived'
|
|
||||||
url: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_ASSETS: ReleaseAsset[] = [
|
const PLATFORM_BADGE: Record<ReleaseAssetPlatform, 'blue' | 'purple' | 'green' | 'orange' | 'cyan'> = {
|
||||||
{
|
windows: 'blue',
|
||||||
id: '1',
|
macos: 'purple',
|
||||||
name: 'D3RO-Voice-Setup-1.0.0-x64.exe',
|
android: 'green',
|
||||||
os: 'Windows',
|
linux: 'orange',
|
||||||
version: '1.0.0',
|
feed: 'cyan',
|
||||||
sizeMb: 102.1,
|
other: 'orange'
|
||||||
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',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function ReleasesManagementPage(): React.ReactElement {
|
function formatSize(sizeBytes: number): string {
|
||||||
const [assets] = useState<ReleaseAsset[]>(INITIAL_ASSETS)
|
if (sizeBytes <= 0) return '—'
|
||||||
const [rolloutPercent, setRolloutPercent] = useState<number>(100)
|
if (sizeBytes < 1024 * 1024) return `${(sizeBytes / 1024).toFixed(1)} KB`
|
||||||
const [forceUpdateEnabled, setForceUpdateEnabled] = useState<boolean>(false)
|
return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
}
|
||||||
|
|
||||||
const copyToClipboard = (text: string, id: string): void => {
|
function formatDate(value: string): string {
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
if (!value) return '—'
|
||||||
setCopiedId(id)
|
const parsed = new Date(value)
|
||||||
setTimeout(() => setCopiedId(null), 2000)
|
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 (
|
return (
|
||||||
<Box sx={{ p: { xs: 2, md: 3 }, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' }, gap: 2 }}>
|
||||||
{/* Header */}
|
{cards.map((card) => (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
<Box key={card.title} sx={{ ...panelSx, p: 2.5 }}>
|
||||||
<Box>
|
<Typography
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 0.5 }}>
|
sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mb: 1, textTransform: 'uppercase', letterSpacing: '0.08em' }}
|
||||||
<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)' },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Git Release Tags
|
{card.title}
|
||||||
</Button>
|
</Typography>
|
||||||
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '22px', fontWeight: 700, color: C.bright, lineHeight: 1.25 }}>
|
||||||
<Button
|
{card.value}
|
||||||
href="http://localhost:3000/download.html"
|
</Typography>
|
||||||
target="_blank"
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>{card.subtext}</Typography>
|
||||||
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>
|
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
|
|
||||||
{/* KPI Cards */}
|
{!latest || latest.assets.length === 0 ? (
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: 'repeat(4, 1fr)' }, gap: 2 }}>
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>
|
||||||
<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' }}>
|
</Typography>
|
||||||
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>
|
|
||||||
|
|
||||||
<Box sx={{ overflowX: 'auto' }}>
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
|
<Box component="table" sx={tableSx}>
|
||||||
<Box component="thead">
|
<Box component="thead">
|
||||||
<Box component="tr" sx={{ borderBottom: `1px solid ${C.border}`, bgcolor: 'rgba(0, 0, 0, 0.2)' }}>
|
<Box component="tr">
|
||||||
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Artifact Name</Box>
|
<Box component="th">Artifact</Box>
|
||||||
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Platform</Box>
|
<Box component="th">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">Size</Box>
|
||||||
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Size</Box>
|
<Box component="th">Downloads</Box>
|
||||||
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Downloads</Box>
|
<Box component="th">SHA-256</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">Link</Box>
|
||||||
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Status</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box component="tbody">
|
<Box component="tbody">
|
||||||
{assets.map((asset) => (
|
{latest.assets.map((asset) => (
|
||||||
<Box
|
<Box component="tr" key={asset.id}>
|
||||||
component="tr"
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
|
||||||
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 }}>
|
|
||||||
{asset.name}
|
{asset.name}
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="td" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '12px', color: C.text }}>
|
<Box component="td">
|
||||||
{asset.os}
|
<Box sx={statusBadgeSx(PLATFORM_BADGE[asset.platform])}>{PLATFORM_LABEL[asset.platform]}</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>{formatSize(asset.sizeBytes)}</Box>
|
||||||
{asset.version}
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
|
||||||
|
{asset.downloadCount.toLocaleString()}
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
|
<Box component="td">
|
||||||
{asset.sizeMb} MB
|
{asset.sha256 ? (
|
||||||
|
<ChecksumCopy value={asset.sha256} />
|
||||||
|
) : (
|
||||||
|
<Typography component="span" sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.muted }}>
|
||||||
|
not published
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '12px', fontWeight: 700, color: C.bright }}>
|
<Box component="td">
|
||||||
{asset.downloads.toLocaleString()}
|
<Typography
|
||||||
</Box>
|
component="a"
|
||||||
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
href={asset.browserDownloadUrl}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
target="_blank"
|
||||||
<span style={{ maxWidth: '160px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
rel="noreferrer"
|
||||||
{asset.sha256}
|
sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.cyanLight, textDecoration: 'none', '&:hover': { color: C.bright } }}
|
||||||
</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)'}`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{asset.status}
|
Download ↗
|
||||||
</Box>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</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>
|
</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>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
// 구독 수정/삭제 클라이언트 컴포넌트
|
// 구독 수정/삭제 클라이언트 컴포넌트
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Box, Button } from '@mui/material'
|
import { Alert, Box, Button } from '@mui/material'
|
||||||
import { d3roFontMono } from '@d3ro/ui/theme'
|
import { d3roFontMono } from '@d3ro/ui/theme'
|
||||||
import { SubscriptionForm } from '@/components/subscription-form'
|
import { SubscriptionForm } from '@/components/subscription-form'
|
||||||
import { MemoDialog } from '@/components/memo-dialog'
|
import { MemoDialog } from '@/components/memo-dialog'
|
||||||
|
|
@ -36,9 +36,11 @@ export function SubscriptionDetailClient({
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleDelete = async (memo: string): Promise<void> => {
|
const handleDelete = async (memo: string): Promise<void> => {
|
||||||
setDeleteLoading(true)
|
setDeleteLoading(true)
|
||||||
|
setDeleteError(null)
|
||||||
try {
|
try {
|
||||||
await callAdminApi(`admin-subscriptions?userId=${userId}`, {
|
await callAdminApi(`admin-subscriptions?userId=${userId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|
@ -46,8 +48,8 @@ export function SubscriptionDetailClient({
|
||||||
})
|
})
|
||||||
setDeleteOpen(false)
|
setDeleteOpen(false)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} catch {
|
} catch (error) {
|
||||||
// error handled in dialog
|
setDeleteError(error instanceof Error ? error.message : 'Subscription deletion failed')
|
||||||
} finally {
|
} finally {
|
||||||
setDeleteLoading(false)
|
setDeleteLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -74,6 +76,7 @@ export function SubscriptionDetailClient({
|
||||||
{/* admin 이상: 삭제 가능 */}
|
{/* admin 이상: 삭제 가능 */}
|
||||||
{canCreateDelete && (
|
{canCreateDelete && (
|
||||||
<>
|
<>
|
||||||
|
{deleteError && <Alert severity="error" sx={{ mt: 2 }}>{deleteError}</Alert>}
|
||||||
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|
|
||||||
|
|
@ -4,57 +4,57 @@
|
||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Typography } from '@mui/material'
|
||||||
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
|
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
|
||||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
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 { requireManager, hasMinRole } from '@/lib/admin-guard'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
import { SubscriptionDetailClient } from './client'
|
import { SubscriptionDetailClient } from './client'
|
||||||
import { fetchUsers } from '@/lib/api-server'
|
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function SubscriptionDetailPage({ params }: PageProps): Promise<React.ReactElement> {
|
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 { id: userId } = await params
|
||||||
const admin = await requireManager()
|
const admin = await requireManager()
|
||||||
const supabase = await getSupabaseServerClient()
|
const supabase = await getSupabaseAdminClient()
|
||||||
|
|
||||||
const [subRes, profileRes, auditRes] = await Promise.all([
|
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('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_id', userId)
|
||||||
.eq('target_type', 'subscription')
|
.eq('target_type', 'subscription')
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(20),
|
.limit(20),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Fallback to mock user
|
if (subRes.error) throw new Error(`Supabase subscription failed: ${subRes.error.message}`)
|
||||||
const allUsers = await fetchUsers()
|
if (profileRes.error) throw new Error(`Supabase profile failed: ${profileRes.error.message}`)
|
||||||
const matchedUser = allUsers.find((u) => String(u.id) === userId || u.uid === userId) || allUsers[0]
|
if (auditRes.error) throw new Error(`Supabase audit log failed: ${auditRes.error.message}`)
|
||||||
|
if (!profileRes.data) notFound()
|
||||||
|
|
||||||
const profile = profileRes.data || {
|
const profile = profileRes.data
|
||||||
id: matchedUser.uid,
|
const sub = subRes.data
|
||||||
name: matchedUser.name,
|
const auditLogs = auditRes.data ?? []
|
||||||
tier: matchedUser.tier,
|
|
||||||
role: matchedUser.role,
|
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 || {
|
const tier = (sub?.tier as string | undefined) || (profile.tier as string) || 'free'
|
||||||
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 isProPlus = tier === 'pro_plus'
|
const isProPlus = tier === 'pro_plus'
|
||||||
const isPro = tier === 'pro'
|
const isPro = tier === 'pro'
|
||||||
|
|
||||||
|
|
@ -90,7 +90,7 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontSize: '20px',
|
fontSize: '20px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
color: C.bright,
|
color: C.bright,
|
||||||
letterSpacing: '-0.02em',
|
letterSpacing: '-0.02em',
|
||||||
m: 0,
|
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 }}>
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1fr 1fr' }, gap: 3 }}>
|
||||||
{/* User Info Card */}
|
{/* User Info Card */}
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<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
|
Account Identifiers
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, fontFamily: FONT_SANS, fontSize: '13px' }}>
|
<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 */}
|
{/* Subscription State Card */}
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<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
|
Contract Status & Pricing
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, fontFamily: FONT_SANS, fontSize: '13px' }}>
|
<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="Plan Level" value={isProPlus ? 'PRO+ VIP' : isPro ? 'PRO' : 'FREE'} />
|
||||||
<Row label="Current Status" value={((sub.status as string) ?? 'active').toUpperCase()} />
|
<Row label="Current Status" value={sub?.status ? String(sub.status).toUpperCase() : 'NO SUBSCRIPTION'} />
|
||||||
<Row label="Payment Gateway" value={((sub.payment_provider as string) ?? 'LemonSqueezy').toUpperCase()} />
|
<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() : 'Auto Renew'} />
|
<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={String(sub.overage_credits ?? 0)} isMono />
|
<Row label="Overage Credits" value={sub ? String(sub.overage_credits) : 'NO SUBSCRIPTION'} isMono />
|
||||||
</Box>
|
</Box>
|
||||||
</DoubleBezelCard>
|
</DoubleBezelCard>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -151,10 +151,10 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
|
||||||
canEdit={true}
|
canEdit={true}
|
||||||
canCreateDelete={hasMinRole(admin, 'admin')}
|
canCreateDelete={hasMinRole(admin, 'admin')}
|
||||||
initialSub={sub ? ({
|
initialSub={sub ? ({
|
||||||
tier: (sub.tier as 'free' | 'pro' | 'pro_plus') ?? 'free',
|
tier: sub.tier as 'free' | 'pro' | 'pro_plus',
|
||||||
status: (sub.status as 'active' | 'canceled' | 'past_due' | 'expired') ?? 'active',
|
status: sub.status as 'active' | 'canceled' | 'past_due' | 'expired',
|
||||||
currentPeriodEnd: (sub.current_period_end as string | null) ?? null,
|
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,
|
adminNote: (sub.admin_note as string | null) ?? null,
|
||||||
}) : undefined}
|
}) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
@ -162,7 +162,7 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
|
||||||
{/* Audit Trail Table */}
|
{/* Audit Trail Table */}
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
<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
|
Subscription Security Audit Trail
|
||||||
</Typography>
|
</Typography>
|
||||||
<TactileBadge tone="mono" mono>
|
<TactileBadge tone="mono" mono>
|
||||||
|
|
@ -181,7 +181,13 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="tbody">
|
<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="tr" key={log.id as number}>
|
||||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
|
||||||
{new Date(log.created_at as string).toLocaleString()}
|
{new Date(log.created_at as string).toLocaleString()}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export function NewSubscriptionClient({ initialUserId }: NewSubscriptionClientPr
|
||||||
size="small"
|
size="small"
|
||||||
value={userId}
|
value={userId}
|
||||||
onChange={(e) => setUserId(e.target.value)}
|
onChange={(e) => setUserId(e.target.value)}
|
||||||
placeholder="e.g. usr_d3ro_001 or Supabase UUID..."
|
placeholder="Supabase user UUID"
|
||||||
sx={{
|
sx={{
|
||||||
'& .MuiOutlinedInput-root': {
|
'& .MuiOutlinedInput-root': {
|
||||||
bgcolor: 'rgba(10, 17, 31, 0.7)',
|
bgcolor: 'rgba(10, 17, 31, 0.7)',
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ export default async function NewSubscriptionPage({ searchParams }: PageProps):
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontSize: '20px',
|
fontSize: '20px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
color: C.bright,
|
color: C.bright,
|
||||||
letterSpacing: '-0.02em',
|
letterSpacing: '-0.02em',
|
||||||
m: 0,
|
m: 0,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
// apps/admin/src/app/(admin)/subscriptions/page.tsx
|
// 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 { Box, Typography, Button } from '@mui/material'
|
||||||
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, filterBtnSx, statusBadgeSx, primaryButtonSx } from '@/lib/console-theme'
|
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 { DoubleBezelCard, TactileBadge, StatRing } from '@d3ro/ui/components/ds'
|
||||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
import { getSupabaseAdminClient, isSupabaseAdminConfigured } from '@/lib/supabase-admin'
|
||||||
import { fetchServerStats } from '@/lib/api-server'
|
import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel'
|
||||||
|
import { hasMinRole, requireManager } from '@/lib/admin-guard'
|
||||||
import { LicenseIssuerButton } from '@/components/license-issuer-button'
|
import { LicenseIssuerButton } from '@/components/license-issuer-button'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
|
@ -19,7 +20,6 @@ interface SubRow {
|
||||||
cancel_at: string | null
|
cancel_at: string | null
|
||||||
renewal_failures: number
|
renewal_failures: number
|
||||||
profile_name: string | null
|
profile_name: string | null
|
||||||
mrrAmount: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
|
|
@ -27,57 +27,68 @@ interface PageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminSubscriptionsPage({ searchParams }: PageProps): Promise<React.ReactElement> {
|
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 params = await searchParams
|
||||||
const statusFilter = params.status ?? 'all'
|
const statusFilter = params.status ?? 'all'
|
||||||
const supabase = await getSupabaseServerClient()
|
if (!['all', 'active', 'canceled', 'past_due', 'expired'].includes(statusFilter)) {
|
||||||
const serverStats = await fetchServerStats()
|
throw new Error('Invalid subscription status filter')
|
||||||
|
}
|
||||||
|
const admin = await requireManager()
|
||||||
|
const supabase = await getSupabaseAdminClient()
|
||||||
|
|
||||||
const subQuery = supabase
|
const rawSubsArr: Array<Record<string, unknown>> = []
|
||||||
.from('subscriptions')
|
for (let page = 0; page < 100; page += 1) {
|
||||||
.select('id, user_id, tier, status, payment_provider, current_period_end, cancel_at')
|
let query = supabase
|
||||||
.order('current_period_end', { ascending: true })
|
.from('subscriptions')
|
||||||
.limit(100)
|
.select('id, user_id, tier, status, payment_provider, current_period_end, cancel_at, renewal_failures')
|
||||||
|
.order('current_period_end', { ascending: true })
|
||||||
if (statusFilter !== 'all') subQuery.eq('status', statusFilter)
|
.range(page * 500, page * 500 + 499)
|
||||||
|
if (statusFilter !== 'all') query = query.eq('status', statusFilter)
|
||||||
const { data: rawSubs } = await subQuery
|
const { data, error } = await query
|
||||||
const rawSubsArr = (rawSubs ?? []) as Array<Record<string, unknown>>
|
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[] = []
|
let subs: SubRow[] = []
|
||||||
|
|
||||||
if (rawSubsArr.length > 0) {
|
if (rawSubsArr.length > 0) {
|
||||||
const userIds = rawSubsArr.map((s) => s.user_id as string)
|
const userIds = rawSubsArr.map((s) => s.user_id as string)
|
||||||
const { data: rawProfiles } = userIds.length > 0
|
const profileChunks = await Promise.all(Array.from({ length: Math.ceil(userIds.length / 200) }, (_, index) =>
|
||||||
? await supabase.from('profiles').select('id, name').in('id', userIds)
|
supabase.from('profiles').select('id, name').in('id', userIds.slice(index * 200, index * 200 + 200))))
|
||||||
: { data: [] }
|
if (profileChunks.some((result) => result.error)) throw new Error('Supabase subscription profiles failed')
|
||||||
|
const rawProfiles = profileChunks.flatMap((result) => result.data ?? [])
|
||||||
const profileMap = new Map(
|
const profileMap = new Map(
|
||||||
((rawProfiles ?? []) as Array<Record<string, unknown>>).map((p) => [p.id as string, (p.name as string) ?? null])
|
((rawProfiles ?? []) as Array<Record<string, unknown>>).map((p) => [p.id as string, (p.name as string) ?? null])
|
||||||
)
|
)
|
||||||
|
|
||||||
subs = rawSubsArr.map((row) => ({
|
subs = rawSubsArr.map((row) => {
|
||||||
id: row.id as string,
|
if (typeof row.id !== 'string' || typeof row.user_id !== 'string' ||
|
||||||
user_id: row.user_id as string,
|
typeof row.tier !== 'string' || typeof row.status !== 'string' ||
|
||||||
tier: (row.tier as string) ?? 'free',
|
typeof row.payment_provider !== 'string' || !Number.isInteger(row.renewal_failures)) {
|
||||||
status: (row.status as string) ?? 'active',
|
throw new Error('Supabase subscription response contains an invalid row')
|
||||||
payment_provider: (row.payment_provider as string) ?? 'Stripe',
|
}
|
||||||
current_period_end: row.current_period_end as string | null,
|
return {
|
||||||
cancel_at: row.cancel_at as string | null,
|
id: row.id,
|
||||||
renewal_failures: (row.renewal_failures as number | undefined) ?? 0,
|
user_id: row.user_id,
|
||||||
profile_name: profileMap.get(row.user_id as string) ?? null,
|
tier: row.tier,
|
||||||
mrrAmount: row.tier === 'enterprise' ? 120 : row.tier === 'team' ? 25 : row.tier === 'pro_plus' ? 19.9 : row.tier === 'pro' ? 9.9 : 0,
|
status: row.status,
|
||||||
}))
|
payment_provider: row.payment_provider,
|
||||||
} else {
|
current_period_end: typeof row.current_period_end === 'string' ? row.current_period_end : null,
|
||||||
// Rich Mock Fallback Subscriptions
|
cancel_at: typeof row.cancel_at === 'string' ? row.cancel_at : null,
|
||||||
subs = [
|
renewal_failures: row.renewal_failures as number,
|
||||||
{ 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 },
|
profile_name: profileMap.get(row.user_id) ?? null,
|
||||||
{ 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 },
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredSubs = statusFilter === 'all' ? subs : subs.filter((s) => s.status === statusFilter)
|
const filteredSubs = statusFilter === 'all' ? subs : subs.filter((s) => s.status === statusFilter)
|
||||||
|
|
@ -114,16 +125,16 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontSize: '20px',
|
fontSize: '20px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
color: C.bright,
|
color: C.bright,
|
||||||
letterSpacing: '-0.02em',
|
letterSpacing: '-0.02em',
|
||||||
m: 0,
|
m: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Subscriptions & ARR Analytics
|
Subscription Operations
|
||||||
</Typography>
|
</Typography>
|
||||||
<TactileBadge ledColor="purple" ledPulse tone="accent" mono>
|
<TactileBadge ledColor="purple" ledPulse tone="accent" mono>
|
||||||
${serverStats.arrUsd.toLocaleString()} ARR
|
{subs.length} RECORDS
|
||||||
</TactileBadge>
|
</TactileBadge>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography
|
<Typography
|
||||||
|
|
@ -135,7 +146,7 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
||||||
mt: 0.25,
|
mt: 0.25,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
LEMONSQUEEZY SYNC • 3-TIER MONETIZATION • AUTO RENEWAL
|
Supabase subscriptions, payment providers, renewal state
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -149,12 +160,14 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<Box sx={{ height: 20, width: '1px', bgcolor: C.borderHl, mx: 0.5 }} />
|
<Box sx={{ height: 20, width: '1px', bgcolor: C.borderHl, mx: 0.5 }} />
|
||||||
<LicenseIssuerButton />
|
{hasMinRole(admin, 'super_admin') && <LicenseIssuerButton />}
|
||||||
<Link href="/subscriptions/new" style={{ textDecoration: 'none' }}>
|
{hasMinRole(admin, 'admin') && (
|
||||||
<Button variant="contained" size="small" sx={primaryButtonSx}>
|
<Link href="/subscriptions/new" style={{ textDecoration: 'none' }}>
|
||||||
+ Create Subscription
|
<Button variant="contained" size="small" sx={primaryButtonSx}>
|
||||||
</Button>
|
+ Create Subscription
|
||||||
</Link>
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|
@ -163,51 +176,51 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
||||||
<DoubleBezelCard interactive bezelPadding="5px" innerPadding="20px">
|
<DoubleBezelCard interactive bezelPadding="5px" innerPadding="20px">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
<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' }}>
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', fontWeight: 600, color: C.dim, textTransform: 'uppercase' }}>
|
||||||
Monthly Recurring (MRR)
|
Total Subscription Records
|
||||||
</Typography>
|
</Typography>
|
||||||
<StatRing color="purple" size={38}>
|
<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>
|
</StatRing>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '24px', fontWeight: 700, color: C.bright }}>
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '24px', fontWeight: 500, color: C.bright }}>
|
||||||
${serverStats.mrrUsd.toLocaleString()}
|
{subs.length.toLocaleString()}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.green400, mt: 0.5 }}>
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.green400, mt: 0.5 }}>
|
||||||
↑ 18.4% vs last month
|
Persisted in Supabase
|
||||||
</Typography>
|
</Typography>
|
||||||
</DoubleBezelCard>
|
</DoubleBezelCard>
|
||||||
|
|
||||||
<DoubleBezelCard interactive bezelPadding="5px" innerPadding="20px">
|
<DoubleBezelCard interactive bezelPadding="5px" innerPadding="20px">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
<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' }}>
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', fontWeight: 600, color: C.dim, textTransform: 'uppercase' }}>
|
||||||
Paid Subscribers (Pro/Pro+)
|
Active Subscriptions
|
||||||
</Typography>
|
</Typography>
|
||||||
<StatRing color="blue" size={38}>
|
<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>
|
</StatRing>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '24px', fontWeight: 700, color: C.bright }}>
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '24px', fontWeight: 500, color: C.bright }}>
|
||||||
{(serverStats.tierDistribution.pro + serverStats.tierDistribution.pro_plus).toLocaleString()} Paid
|
{subs.filter((subscription) => subscription.status === 'active').length.toLocaleString()}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.cyanLight, mt: 0.5 }}>
|
<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>
|
</Typography>
|
||||||
</DoubleBezelCard>
|
</DoubleBezelCard>
|
||||||
|
|
||||||
<DoubleBezelCard interactive bezelPadding="5px" innerPadding="20px">
|
<DoubleBezelCard interactive bezelPadding="5px" innerPadding="20px">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
<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' }}>
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', fontWeight: 600, color: C.dim, textTransform: 'uppercase' }}>
|
||||||
Renewal Success Rate
|
Renewal Failures
|
||||||
</Typography>
|
</Typography>
|
||||||
<StatRing color="green" size={38}>
|
<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>
|
</StatRing>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '24px', fontWeight: 700, color: C.green400 }}>
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '24px', fontWeight: 500, color: C.green400 }}>
|
||||||
99.4%
|
{subs.reduce((total, subscription) => total + subscription.renewal_failures, 0).toLocaleString()}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
|
||||||
0.6% Churn • Fast Retry System
|
Persisted retry failures
|
||||||
</Typography>
|
</Typography>
|
||||||
</DoubleBezelCard>
|
</DoubleBezelCard>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -215,8 +228,8 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
||||||
{/* Subscription Table */}
|
{/* Subscription Table */}
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
|
<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 Subscription Contracts
|
Subscription Contracts
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||||
SHOWING {filteredSubs.length} RECORDS
|
SHOWING {filteredSubs.length} RECORDS
|
||||||
|
|
@ -230,7 +243,7 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
||||||
<Box component="th">SUBSCRIBER</Box>
|
<Box component="th">SUBSCRIBER</Box>
|
||||||
<Box component="th">TIER</Box>
|
<Box component="th">TIER</Box>
|
||||||
<Box component="th">STATUS</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">PROVIDER</Box>
|
||||||
<Box component="th">EXPIRES / RENEWS</Box>
|
<Box component="th">EXPIRES / RENEWS</Box>
|
||||||
<Box component="th" sx={{ textAlign: 'right' }}>ACTION</Box>
|
<Box component="th" sx={{ textAlign: 'right' }}>ACTION</Box>
|
||||||
|
|
@ -269,14 +282,14 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
||||||
{s.status.toUpperCase()}
|
{s.status.toUpperCase()}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '13px', fontWeight: 700, color: C.bright }}>
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '13px', fontWeight: 500, color: C.bright }}>
|
||||||
${s.mrrAmount}/mo
|
{s.renewal_failures}
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||||
{s.payment_provider}
|
{s.payment_provider}
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.text }}>
|
<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>
|
||||||
<Box component="td" sx={{ textAlign: 'right' }}>
|
<Box component="td" sx={{ textAlign: 'right' }}>
|
||||||
<Link href={`/subscriptions/${s.user_id}`} style={{ textDecoration: 'none' }}>
|
<Link href={`/subscriptions/${s.user_id}`} style={{ textDecoration: 'none' }}>
|
||||||
|
|
|
||||||
|
|
@ -1,315 +1,11 @@
|
||||||
// apps/admin/src/app/(admin)/support/page.tsx
|
import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel'
|
||||||
// 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.',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
export default function AdminSupportPage(): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<>
|
<UnavailableAdminPanel
|
||||||
{/* Header */}
|
title="Customer Support Desk"
|
||||||
<Box
|
capability="Tickets, SLA, diagnostics, refunds"
|
||||||
sx={{
|
reason="지원 티켓 공급자와 진단 수집 시스템의 인증된 서버 계약이 구성되지 않았습니다. 고객·장치·SLA 정보를 추정하거나 가짜 티켓으로 대체하지 않습니다."
|
||||||
...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>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontSize: '20px',
|
fontSize: '20px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
color: C.bright,
|
color: C.bright,
|
||||||
letterSpacing: '-0.02em',
|
letterSpacing: '-0.02em',
|
||||||
m: 0,
|
m: 0,
|
||||||
|
|
@ -115,7 +115,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
|
||||||
mt: 0.25,
|
mt: 0.25,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
REALTIME STT AUDIO MINUTES • LLM TOKEN COUNTER • CLOUD PROVIDER ATTRIBUTION
|
Realtime STT audio minutes, LLM token counter, cloud provider attribution
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -141,7 +141,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
|
||||||
{m.icon}
|
{m.icon}
|
||||||
</StatRing>
|
</StatRing>
|
||||||
</Box>
|
</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}
|
{m.value}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.text, mt: 0.5 }}>
|
<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 */}
|
{/* STT Provider Usage Breakdown Table */}
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
<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
|
🎙️ Speech-to-Text (STT) Transcription Metrics by Provider
|
||||||
</Typography>
|
</Typography>
|
||||||
<TactileBadge tone="accent" mono>
|
<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 }}>
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
|
||||||
{p.avgLatencyMs}ms
|
{p.avgLatencyMs}ms
|
||||||
</Box>
|
</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)}
|
${p.totalCost.toFixed(4)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -206,7 +206,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
|
||||||
|
|
||||||
{/* Feature Token Distribution Visual Progress Bars */}
|
{/* Feature Token Distribution Visual Progress Bars */}
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<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
|
Token & Compute Distribution by Feature
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
|
@ -240,7 +240,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
|
||||||
{/* User Breakdown Table */}
|
{/* User Breakdown Table */}
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
<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
|
Cost Attribution by Top Power Users
|
||||||
</Typography>
|
</Typography>
|
||||||
<TactileBadge tone="mono" mono>
|
<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 }}>
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
|
||||||
{u.totalTokens.toLocaleString()}
|
{u.totalTokens.toLocaleString()}
|
||||||
</Box>
|
</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)}
|
${u.totalCost.toFixed(6)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -287,7 +287,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
|
||||||
{/* LLM Model Breakdown Table */}
|
{/* LLM Model Breakdown Table */}
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
<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
|
🧠 Cost & Token Breakdown by LLM Model Engine
|
||||||
</Typography>
|
</Typography>
|
||||||
<TactileBadge tone="accent" mono>
|
<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 }}>
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
|
||||||
{m.totalTokens.toLocaleString()}
|
{m.totalTokens.toLocaleString()}
|
||||||
</Box>
|
</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)}
|
${m.totalCost.toFixed(6)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -4,63 +4,60 @@
|
||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Typography } from '@mui/material'
|
||||||
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
|
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
|
||||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
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 { requireManager, isAdmin } from '@/lib/admin-guard'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
import { RoleChangeButton } from './role-change-button'
|
import { RoleChangeButton } from './role-change-button'
|
||||||
import { PaymentHistory } from '@/components/payment-history'
|
import { PaymentHistory } from '@/components/payment-history'
|
||||||
import { fetchUsers } from '@/lib/api-server'
|
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminUserDetailPage({ params }: PageProps): Promise<React.ReactElement> {
|
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 { id } = await params
|
||||||
const admin = await requireManager()
|
const admin = await requireManager()
|
||||||
const supabase = await getSupabaseServerClient()
|
const supabase = await getSupabaseAdminClient()
|
||||||
|
|
||||||
const [profileRes, subRes, usageRes] = await Promise.all([
|
const [authRes, profileRes, subRes, usageRes] = await Promise.all([
|
||||||
supabase.from('profiles').select('*').eq('id', id).maybeSingle(),
|
supabase.auth.admin.getUserById(id),
|
||||||
supabase.from('subscriptions').select('*').eq('user_id', id).maybeSingle(),
|
supabase.from('profiles').select('id, name, locale, tier, role, created_at, updated_at').eq('id', id).maybeSingle(),
|
||||||
supabase.from('daily_usage').select('*')
|
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)
|
.eq('user_id', id)
|
||||||
.gte('date', new Date(Date.now() - 30 * 86400000).toISOString().split('T')[0])
|
.gte('date', new Date(Date.now() - 30 * 86400000).toISOString().split('T')[0])
|
||||||
.order('date', { ascending: false }),
|
.order('date', { ascending: false }),
|
||||||
])
|
])
|
||||||
|
|
||||||
// If supabase profile not found, fallback to rich mock user dataset
|
if (authRes.error) throw new Error(`Supabase auth user failed: ${authRes.error.message}`)
|
||||||
const allUsers = await fetchUsers()
|
if (profileRes.error) throw new Error(`Supabase profile failed: ${profileRes.error.message}`)
|
||||||
const matchedMock = allUsers.find((u) => String(u.id) === id || u.uid === id) || allUsers[0]
|
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 || {
|
const authUser = authRes.data.user
|
||||||
id: matchedMock.uid,
|
const profile = {
|
||||||
name: matchedMock.name,
|
...profileRes.data,
|
||||||
email: matchedMock.email,
|
email: authUser.email ?? null,
|
||||||
tier: matchedMock.tier,
|
last_login: authUser.last_sign_in_at ?? null,
|
||||||
role: matchedMock.role,
|
|
||||||
locale: 'ko-KR',
|
|
||||||
created_at: matchedMock.createdAt,
|
|
||||||
last_login: matchedMock.lastLoginAt,
|
|
||||||
last_device: matchedMock.lastActiveDevice,
|
|
||||||
}
|
}
|
||||||
|
const sub = subRes.data
|
||||||
|
const usage = usageRes.data ?? []
|
||||||
|
|
||||||
const sub = subRes.data || {
|
const tier = typeof profile.tier === 'string' && ['free', 'pro', 'pro_plus'].includes(profile.tier)
|
||||||
status: 'active',
|
? profile.tier
|
||||||
payment_provider: 'LemonSqueezy',
|
: null
|
||||||
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 isProPlus = tier === 'pro_plus'
|
const isProPlus = tier === 'pro_plus'
|
||||||
const isPro = tier === 'pro'
|
const isPro = tier === 'pro'
|
||||||
const userRole = ((profile.role as string) ?? 'user') as 'user' | 'manager' | 'admin' | 'super_admin'
|
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={{
|
sx={{
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontSize: '20px',
|
fontSize: '20px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
color: C.bright,
|
color: C.bright,
|
||||||
letterSpacing: '-0.02em',
|
letterSpacing: '-0.02em',
|
||||||
m: 0,
|
m: 0,
|
||||||
|
|
@ -106,7 +103,7 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
||||||
User Profile Console
|
User Profile Console
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box component="span" sx={statusBadgeSx(isProPlus ? 'purple' : isPro ? 'green' : 'blue')}>
|
<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>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 0.25 }}>
|
<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',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
fontSize: '20px',
|
fontSize: '20px',
|
||||||
boxShadow: '0 0 20px rgba(59, 130, 246, 0.4)',
|
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>
|
||||||
<Box>
|
<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 }}>
|
||||||
{(profile.name as string) || 'D3RO User'}
|
{(profile.name as string) || 'Name not provided'}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
|
||||||
{(profile.email as string) || 'user@d3ro.voice'}
|
{(profile.email as string) || 'Email unavailable'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</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' }}>
|
<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="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="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="Language Locale" value={(profile.locale as string) || 'Not reported'} isMono />
|
||||||
<Row label="Active Platform" value={(profile.last_device as string) || 'Windows 11 x64 (Build 26100)'} />
|
|
||||||
<Row label="Created Date" value={new Date(profile.created_at as string).toLocaleDateString()} />
|
<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>
|
</Box>
|
||||||
</DoubleBezelCard>
|
</DoubleBezelCard>
|
||||||
|
|
||||||
{/* Subscription & Quota Card */}
|
{/* Subscription & Quota Card */}
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
<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
|
Subscription & Quota Entitlements
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box component="span" sx={statusBadgeSx((sub.status as string) === 'active' ? 'green' : 'red')}>
|
<Box component="span" sx={statusBadgeSx((sub?.status as string) === 'active' ? 'green' : 'red')}>
|
||||||
{((sub.status as string) || 'ACTIVE').toUpperCase()}
|
{sub?.status ? String(sub.status).toUpperCase() : 'NO SUBSCRIPTION'}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, mb: 3 }}>
|
<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="Plan Tier" value={isProPlus ? 'PRO+ VIP' : isPro ? 'PRO' : tier === 'free' ? 'FREE' : 'Unavailable'} />
|
||||||
<Row label="Billing Provider" value={(sub.payment_provider as string) || 'LemonSqueezy'} />
|
<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() : 'Auto Renew'} />
|
<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() : 'None (Active)'} />
|
<Row label="Cancel Scheduled" value={sub?.cancel_at ? new Date(sub.cancel_at as string).toLocaleDateString() : 'Not scheduled'} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ p: 2, borderRadius: '12px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
|
<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 }}>
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', fontWeight: 600, color: C.bright, mb: 1 }}>
|
||||||
Phase 1~15.5 Enabled Features
|
Entitlement source
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
<TactileBadge tone="success" mono>Whisper Turbo STT (Unlimited)</TactileBadge>
|
<TactileBadge tone="mono" mono>{sub ? 'SUPABASE SUBSCRIPTION' : 'NO ACTIVE CONTRACT RECORD'}</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>
|
||||||
</Box>
|
</Box>
|
||||||
</DoubleBezelCard>
|
</DoubleBezelCard>
|
||||||
</Box>
|
</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 */}
|
{/* 30-Day Activity Heatmap Table */}
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
<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
|
30-Day Feature Execution Telemetry
|
||||||
</Typography>
|
</Typography>
|
||||||
<TactileBadge tone="mono" mono>
|
<TactileBadge tone="mono" mono>
|
||||||
|
|
@ -230,20 +236,26 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="tbody">
|
<Box component="tbody">
|
||||||
{usage.map((row, i) => (
|
{usage.length === 0 ? (
|
||||||
<Box component="tr" key={i}>
|
<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 }}>
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
|
||||||
{row.date as string}
|
{row.date as string}
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="td" sx={{ fontWeight: 600, color: C.bright }}>
|
<Box component="td" sx={{ fontWeight: 600, color: C.bright }}>
|
||||||
{row.feature as string}
|
{row.feature as string}
|
||||||
</Box>
|
</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
|
{row.count as number} calls
|
||||||
</Box>
|
</Box>
|
||||||
<Box component="td">
|
<Box component="td">
|
||||||
<Box component="span" sx={statusBadgeSx('green')}>
|
<Box component="span" sx={statusBadgeSx('green')}>
|
||||||
SUCCESS
|
MEASURED
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -256,11 +268,11 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
||||||
{/* Payment History */}
|
{/* Payment History */}
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
<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
|
Payment History & Invoices
|
||||||
</Typography>
|
</Typography>
|
||||||
<TactileBadge tone="success" mono>
|
<TactileBadge tone="success" mono>
|
||||||
LEMONSQUEEZY VERIFIED
|
PAYMENT DATA
|
||||||
</TactileBadge>
|
</TactileBadge>
|
||||||
</Box>
|
</Box>
|
||||||
<PaymentHistory userId={id} />
|
<PaymentHistory userId={id} />
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Box, Typography, Button } from '@mui/material'
|
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 { C, FONT_SANS, FONT_MONO, panelSx, tableSx, filterBtnSx, statusBadgeSx } from '@/lib/console-theme'
|
||||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
@ -13,12 +14,25 @@ interface PageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminUsersPage({ searchParams }: PageProps): Promise<React.ReactElement> {
|
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 params = await searchParams
|
||||||
const tierFilter = params.tier ?? 'all'
|
const tierFilter = params.tier ?? 'all'
|
||||||
const roleFilter = params.role ?? '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) => {
|
const filteredUsers = allUsers.filter((u) => {
|
||||||
if (tierFilter !== 'all' && u.tier !== tierFilter) return false
|
if (tierFilter !== 'all' && u.tier !== tierFilter) return false
|
||||||
|
|
@ -64,7 +78,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontSize: '20px',
|
fontSize: '20px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
color: C.bright,
|
color: C.bright,
|
||||||
letterSpacing: '-0.02em',
|
letterSpacing: '-0.02em',
|
||||||
m: 0,
|
m: 0,
|
||||||
|
|
@ -85,7 +99,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
|
||||||
mt: 0.25,
|
mt: 0.25,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
MULTI-TIER QUOTAS • HARDWARE SESSIONS • ROLES & ACCESS CONTROL
|
Supabase auth accounts, subscription tiers, roles and access control
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -105,8 +119,8 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
|
||||||
{/* Main Table Card */}
|
{/* Main Table Card */}
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
|
<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 }}>
|
||||||
User Account Profiles & Quota Consumption
|
User Account Profiles
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||||
SHOWING {filteredUsers.length} OF {allUsers.length} USERS
|
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">USER PROFILE</Box>
|
||||||
<Box component="th">TIER</Box>
|
<Box component="th">TIER</Box>
|
||||||
<Box component="th">ROLE</Box>
|
<Box component="th">ROLE</Box>
|
||||||
<Box component="th">DAILY QUOTA STATUS</Box>
|
<Box component="th">USAGE</Box>
|
||||||
<Box component="th">LAST ACTIVE PLATFORM</Box>
|
<Box component="th">LAST SIGN-IN</Box>
|
||||||
<Box component="th">STATUS</Box>
|
<Box component="th">STATUS</Box>
|
||||||
<Box component="th" sx={{ textAlign: 'right' }}>ACTION</Box>
|
<Box component="th" sx={{ textAlign: 'right' }}>ACTION</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -157,7 +171,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
boxShadow: isProPlus ? '0 0 12px rgba(168, 85, 247, 0.4)' : 'none',
|
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}
|
{u.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||||
{u.email}
|
{u.email || 'Email not set'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -179,7 +193,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
|
||||||
{/* Tier Badge */}
|
{/* Tier Badge */}
|
||||||
<Box component="td">
|
<Box component="td">
|
||||||
<Box component="span" sx={statusBadgeSx(isProPlus ? 'purple' : isPro ? 'green' : 'blue')}>
|
<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>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|
@ -192,25 +206,14 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
|
||||||
|
|
||||||
{/* Daily Quota Status */}
|
{/* Daily Quota Status */}
|
||||||
<Box component="td">
|
<Box component="td">
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', fontFamily: FONT_MONO }}>
|
Open user detail for measured usage
|
||||||
<span style={{ color: C.dim }}>Dictations:</span>
|
</Typography>
|
||||||
<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>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Last Active Platform */}
|
{/* Last Active Platform */}
|
||||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.text }}>
|
<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>
|
</Box>
|
||||||
|
|
||||||
{/* Status */}
|
{/* 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 { 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> {
|
export async function POST(request: Request): Promise<NextResponse> {
|
||||||
|
let body: unknown
|
||||||
try {
|
try {
|
||||||
// 1. Extract Client IP & Identifier for Rate Limiting
|
body = await request.json()
|
||||||
const forwardedFor = request.headers.get('x-forwarded-for') || request.headers.get('cf-connecting-ip') || '127.0.0.1'
|
} catch {
|
||||||
const clientIp = forwardedFor.split(',')[0].trim()
|
return failure('invalid_request', 400)
|
||||||
|
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
|
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
|
// D3RO Voice — Admin Session Logout Handler
|
||||||
|
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { adminCookieSecure } from '@/lib/admin-session'
|
||||||
|
|
||||||
export async function POST(): Promise<NextResponse> {
|
export async function POST(): Promise<NextResponse> {
|
||||||
const response = NextResponse.json({ success: true, message: 'Logged out successfully' })
|
const response = NextResponse.json({ success: true, message: 'Logged out successfully' })
|
||||||
|
|
@ -10,7 +11,7 @@ export async function POST(): Promise<NextResponse> {
|
||||||
name: 'd3ro_admin_session',
|
name: 'd3ro_admin_session',
|
||||||
value: '',
|
value: '',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: adminCookieSecure(),
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: 0,
|
maxAge: 0,
|
||||||
|
|
@ -28,7 +29,7 @@ export async function GET(request: Request): Promise<NextResponse> {
|
||||||
name: 'd3ro_admin_session',
|
name: 'd3ro_admin_session',
|
||||||
value: '',
|
value: '',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: adminCookieSecure(),
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
path: '/',
|
path: '/',
|
||||||
maxAge: 0,
|
maxAge: 0,
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,21 @@
|
||||||
/* D3RO Voice Admin CRM — "Midnight Glass v2" Global Styles */
|
/* D3RO Voice Admin CRM — "Midnight Glass v2" Global Styles */
|
||||||
|
/* 폰트는 layout.tsx <head>의 Pretendard Variable 다이나믹 서브셋 링크로 로드한다
|
||||||
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
|
(렌더를 막는 @import 이중 로드는 제거).
|
||||||
@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');
|
색 변수는 @d3ro/ui SSOT가 MuiCssBaseline로 주입하는 --d3-*를 그대로 쓴다
|
||||||
|
(관리자는 고정 다크: 값 미지정 시 아래 폴백). */
|
||||||
: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;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 의미적 강조는 세미볼드(600)까지 — 볼드(700+) 타이포그래피 금지 */
|
||||||
|
strong, b { font-weight: 600; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background-color: var(--d3-bg-base);
|
background-color: var(--d3-bg-app, #070b16);
|
||||||
color: var(--text-bright);
|
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;
|
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;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,10 @@ export default function RootLayout({
|
||||||
<head>
|
<head>
|
||||||
<title>D3RO Voice — Admin & Intelligence CRM</title>
|
<title>D3RO Voice — Admin & Intelligence CRM</title>
|
||||||
<meta name="description" content="D3RO Voice AI Voice Assistant Administration Console" />
|
<meta name="description" content="D3RO Voice AI Voice Assistant Administration Console" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
{/* Pretendard Variable 다이나믹 서브셋 — 화면에 쓰인 글리프만 분할 로드 (한글 웹폰트 표준).
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
mono는 시스템 스택(ui-monospace)을 쓰므로 별도 웹폰트를 로드하지 않는다. */}
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossOrigin="" />
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css" />
|
||||||
</head>
|
</head>
|
||||||
<body suppressHydrationWarning>
|
<body suppressHydrationWarning>
|
||||||
<div className="admin-ambient-glow" />
|
<div className="admin-ambient-glow" />
|
||||||
|
|
|
||||||
|
|
@ -17,15 +17,33 @@ function LoginForm(): React.ReactElement {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const redirectPath = searchParams.get('redirect') || '/'
|
const redirectPath = searchParams.get('redirect') || '/'
|
||||||
|
|
||||||
const [usernameOrEmail, setUsernameOrEmail] = useState('admin')
|
const [usernameOrEmail, setUsernameOrEmail] = useState('')
|
||||||
const [password, setPassword] = useState('Test1234!')
|
const [password, setPassword] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
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> => {
|
const handleLogin = async (e?: React.FormEvent): Promise<void> => {
|
||||||
if (e) e.preventDefault()
|
if (e) e.preventDefault()
|
||||||
if (!usernameOrEmail || !password) {
|
if (!usernameOrEmail || !password) {
|
||||||
setErrorMsg('아이디와 비밀번호를 입력해주세요.')
|
setErrorMsg('이메일과 비밀번호를 입력해주세요.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!usernameOrEmail.includes('@')) {
|
||||||
|
setErrorMsg('관리자 이메일 주소로 로그인해주세요. (username 로그인은 지원이 종료되었습니다)')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,7 +60,7 @@ function LoginForm(): React.ReactElement {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
if (!res.ok || !data.success) {
|
if (!res.ok || !data.success) {
|
||||||
throw new Error(data.message || '인증에 실패했습니다.')
|
throw new Error(describeLoginError(data.error, data.retryAfter))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful login
|
// Successful login
|
||||||
|
|
@ -117,7 +135,7 @@ function LoginForm(): React.ReactElement {
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontSize: '22px',
|
fontSize: '22px',
|
||||||
fontWeight: 800,
|
fontWeight: 600,
|
||||||
letterSpacing: '-0.02em',
|
letterSpacing: '-0.02em',
|
||||||
color: C.bright,
|
color: C.bright,
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
|
|
@ -165,13 +183,14 @@ function LoginForm(): React.ReactElement {
|
||||||
{/* Login Form */}
|
{/* Login Form */}
|
||||||
<Box component="form" onSubmit={handleLogin} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box component="form" onSubmit={handleLogin} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Admin Identifier (Username / Email)"
|
label="Admin Email"
|
||||||
|
type="email"
|
||||||
value={usernameOrEmail}
|
value={usernameOrEmail}
|
||||||
onChange={(e) => setUsernameOrEmail(e.target.value)}
|
onChange={(e) => setUsernameOrEmail(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
required
|
required
|
||||||
autoComplete="username"
|
autoComplete="email"
|
||||||
sx={{
|
sx={{
|
||||||
'& .MuiOutlinedInput-root': {
|
'& .MuiOutlinedInput-root': {
|
||||||
bgcolor: 'rgba(10, 17, 31, 0.8)',
|
bgcolor: 'rgba(10, 17, 31, 0.8)',
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export default function UnauthorizedPage(): React.ReactElement {
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontSize: '22px',
|
fontSize: '22px',
|
||||||
fontWeight: 800,
|
fontWeight: 600,
|
||||||
color: C.bright,
|
color: C.bright,
|
||||||
mb: 1,
|
mb: 1,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@ interface NavGroup {
|
||||||
key: string
|
key: string
|
||||||
path: string
|
path: string
|
||||||
label: string
|
label: string
|
||||||
badge?: string
|
|
||||||
badgeColor?: 'blue' | 'purple' | 'green' | 'orange'
|
|
||||||
icon: React.ReactElement
|
icon: React.ReactElement
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
@ -38,8 +36,6 @@ const NAV_GROUPS: NavGroup[] = [
|
||||||
key: 'pipelines',
|
key: 'pipelines',
|
||||||
path: '/pipelines',
|
path: '/pipelines',
|
||||||
label: 'AI & Voice Pipelines',
|
label: 'AI & Voice Pipelines',
|
||||||
badge: 'v0.2',
|
|
||||||
badgeColor: 'blue',
|
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 100-6 3 3 0 000 6z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 100-6 3 3 0 000 6z" />
|
||||||
|
|
@ -60,8 +56,6 @@ const NAV_GROUPS: NavGroup[] = [
|
||||||
key: 'releases',
|
key: 'releases',
|
||||||
path: '/releases',
|
path: '/releases',
|
||||||
label: 'Release & Downloads',
|
label: 'Release & Downloads',
|
||||||
badge: 'v1.0',
|
|
||||||
badgeColor: 'green',
|
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
|
@ -77,8 +71,6 @@ const NAV_GROUPS: NavGroup[] = [
|
||||||
key: 'users',
|
key: 'users',
|
||||||
path: '/users',
|
path: '/users',
|
||||||
label: 'User Directory',
|
label: 'User Directory',
|
||||||
badge: '4.5k',
|
|
||||||
badgeColor: 'purple',
|
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
|
@ -99,8 +91,6 @@ const NAV_GROUPS: NavGroup[] = [
|
||||||
key: 'ads',
|
key: 'ads',
|
||||||
path: '/ads',
|
path: '/ads',
|
||||||
label: 'Ad Monetization',
|
label: 'Ad Monetization',
|
||||||
badge: '$4.6k',
|
|
||||||
badgeColor: 'blue',
|
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
|
@ -111,8 +101,6 @@ const NAV_GROUPS: NavGroup[] = [
|
||||||
key: 'support',
|
key: 'support',
|
||||||
path: '/support',
|
path: '/support',
|
||||||
label: 'Customer Support (CA)',
|
label: 'Customer Support (CA)',
|
||||||
badge: '4 Live',
|
|
||||||
badgeColor: 'orange',
|
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||||
|
|
@ -148,7 +136,9 @@ const NAV_GROUPS: NavGroup[] = [
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function AdminSidebar(): React.ReactElement {
|
export function AdminSidebar({ identity }: {
|
||||||
|
identity: { email: string; role: 'manager' | 'admin' | 'super_admin' }
|
||||||
|
}): React.ReactElement {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
|
@ -239,7 +229,7 @@ export function AdminSidebar(): React.ReactElement {
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontSize: '15px',
|
fontSize: '15px',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
letterSpacing: '-0.02em',
|
letterSpacing: '-0.02em',
|
||||||
color: C.bright,
|
color: C.bright,
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
|
|
@ -261,7 +251,7 @@ export function AdminSidebar(): React.ReactElement {
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Live Node Pulse */}
|
{/* Verified local session indicator */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -285,7 +275,7 @@ export function AdminSidebar(): React.ReactElement {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '9px', fontWeight: 600, color: '#34d399' }}>
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '9px', fontWeight: 600, color: '#34d399' }}>
|
||||||
LIVE
|
SESSION
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -377,63 +367,12 @@ export function AdminSidebar(): React.ReactElement {
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{item.badge && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
px: 0.9,
|
|
||||||
py: 0.2,
|
|
||||||
borderRadius: '999px',
|
|
||||||
fontSize: '10px',
|
|
||||||
fontFamily: FONT_MONO,
|
|
||||||
fontWeight: 700,
|
|
||||||
bgcolor: item.badgeColor === 'blue' ? 'rgba(59, 130, 246, 0.2)' : 'rgba(139, 92, 246, 0.2)',
|
|
||||||
color: item.badgeColor === 'blue' ? C.cyanLight : C.purple400,
|
|
||||||
border: `1px solid ${item.badgeColor === 'blue' ? 'rgba(59, 130, 246, 0.3)' : 'rgba(139, 92, 246, 0.3)'}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.badge}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Live Service Matrix Mini-Widget */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
mt: 1,
|
|
||||||
p: 2,
|
|
||||||
borderRadius: '14px',
|
|
||||||
bgcolor: 'rgba(13, 21, 38, 0.7)',
|
|
||||||
border: `1px solid ${C.border}`,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 1.2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', fontWeight: 600, color: C.dim, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
|
||||||
Service Telemetry
|
|
||||||
</Typography>
|
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: '#10b981' }}>
|
|
||||||
99.9% Up
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1 }}>
|
|
||||||
<Box sx={{ p: 1, borderRadius: '8px', bgcolor: 'rgba(17, 26, 48, 0.6)', border: `1px solid ${C.border}` }}>
|
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '9px', color: C.dim }}>STT LATENCY</Typography>
|
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', fontWeight: 700, color: C.cyanLight }}>142ms</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ p: 1, borderRadius: '8px', bgcolor: 'rgba(17, 26, 48, 0.6)', border: `1px solid ${C.border}` }}>
|
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '9px', color: C.dim }}>OLLAMA VRAM</Typography>
|
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', fontWeight: 700, color: C.purple400 }}>4.6 GB</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* User Footer */}
|
{/* User Footer */}
|
||||||
|
|
@ -471,20 +410,20 @@ export function AdminSidebar(): React.ReactElement {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontWeight: 700,
|
fontWeight: 500,
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
boxShadow: '0 0 12px rgba(59, 130, 246, 0.4)',
|
boxShadow: '0 0 12px rgba(59, 130, 246, 0.4)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
A
|
{identity.email.slice(0, 1).toUpperCase()}
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', fontWeight: 600, color: C.bright, lineHeight: 1.2 }}>
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', fontWeight: 600, color: C.bright, lineHeight: 1.2 }}>
|
||||||
Admin User
|
{identity.email}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '9px', fontWeight: 600, color: C.purple400, letterSpacing: '0.04em' }}>
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '9px', fontWeight: 600, color: C.purple400, letterSpacing: '0.04em' }}>
|
||||||
SUPER_ADMIN
|
{identity.role.toUpperCase()}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -516,4 +455,3 @@ export function AdminSidebar(): React.ReactElement {
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
49
apps/admin/src/components/checksum-copy.tsx
Normal file
49
apps/admin/src/components/checksum-copy.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Box, Tooltip } from '@mui/material'
|
||||||
|
import { Check, Copy } from 'lucide-react'
|
||||||
|
import { C, FONT_MONO } from '@/lib/console-theme'
|
||||||
|
|
||||||
|
interface ChecksumCopyProps {
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChecksumCopy({ value }: ChecksumCopyProps): React.ReactElement {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
const handleCopy = (): void => {
|
||||||
|
void navigator.clipboard.writeText(value).then(() => {
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 1, fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||||
|
<Box component="span" sx={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{value}
|
||||||
|
</Box>
|
||||||
|
<Tooltip title={copied ? 'Copied' : 'Copy SHA-256'} placement="top">
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
aria-label="Copy SHA-256 checksum"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
p: 0.25,
|
||||||
|
border: 'none',
|
||||||
|
bgcolor: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: copied ? C.green400 : C.cyanLight,
|
||||||
|
'&:hover': { color: C.bright }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
'use client'
|
|
||||||
|
|
||||||
// apps/admin/src/components/dashboard-simulator.tsx
|
|
||||||
// D3RO Voice — Interactive Realtime Audio & Intelligence Simulator Sandbox
|
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
|
||||||
import { Box, Typography, Button, TextField } from '@mui/material'
|
|
||||||
import { C, FONT_SANS, FONT_MONO } from '@/lib/console-theme'
|
|
||||||
import { DoubleBezelCard, TactileBadge, StatRing } from '@d3ro/ui/components/ds'
|
|
||||||
|
|
||||||
export function DashboardSimulator(): React.ReactElement {
|
|
||||||
const [mode, setMode] = useState<'dictation' | 'meeting' | 'rag'>('dictation')
|
|
||||||
const [isRunning, setIsRunning] = useState(false)
|
|
||||||
const [step, setStep] = useState<number>(0)
|
|
||||||
const [interimText, setInterimText] = useState('')
|
|
||||||
const [finalResult, setFinalResult] = useState<Record<string, unknown> | null>(null)
|
|
||||||
const [searchQuery, setSearchQuery] = useState('프로젝트 출시 일정 및 마일스톤')
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
{ label: 'Audio Capture', desc: '16kHz Mono PCM Buffer' },
|
|
||||||
{ label: 'Whisper STT', desc: 'Faster-Whisper large-v3-turbo' },
|
|
||||||
{ label: 'LLM Orchestrator', desc: 'Ollama gemma4 / GPT-Realtime' },
|
|
||||||
{ label: 'Context / Export', desc: 'SQLite RAG / Multi-Doc' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const runSimulation = () => {
|
|
||||||
setIsRunning(true)
|
|
||||||
setStep(1)
|
|
||||||
setInterimText('')
|
|
||||||
setFinalResult(null)
|
|
||||||
|
|
||||||
// Step 1: Audio buffer
|
|
||||||
setTimeout(() => {
|
|
||||||
setStep(2)
|
|
||||||
if (mode === 'dictation') {
|
|
||||||
setInterimText('오늘 회의에서 논의된 새로운 음성 인식 모델 성능에 대해서...')
|
|
||||||
} else if (mode === 'meeting') {
|
|
||||||
setInterimText('[화자 1]: 다음 주 스프린트 목표를 검토합시다. [화자 2]: STT 지연 시간을 140ms 이하로 줄였습니다.')
|
|
||||||
} else {
|
|
||||||
setInterimText('Query vector generated via nomic-embed-text (512-dim)...')
|
|
||||||
}
|
|
||||||
}, 600)
|
|
||||||
|
|
||||||
// Step 2: STT + interim stream
|
|
||||||
setTimeout(() => {
|
|
||||||
setStep(3)
|
|
||||||
if (mode === 'dictation') {
|
|
||||||
setInterimText('오늘 회의에서 논의된 새로운 음성 인식 모델 성능에 대해서 요약 및 문서화를 요청드립니다.')
|
|
||||||
}
|
|
||||||
}, 1300)
|
|
||||||
|
|
||||||
// Step 3: LLM & Final Result
|
|
||||||
setTimeout(() => {
|
|
||||||
setStep(4)
|
|
||||||
setIsRunning(false)
|
|
||||||
if (mode === 'dictation') {
|
|
||||||
setFinalResult({
|
|
||||||
status: 'success',
|
|
||||||
engine: 'Whisper large-v3-turbo + Ollama gemma4:e4b',
|
|
||||||
latencyMs: 142,
|
|
||||||
speedup: '6.2x',
|
|
||||||
originalText: '오늘 회의에서 논의된 새로운 음성 인식 모델 성능에 대해서 요약 및 문서화를 요청드립니다.',
|
|
||||||
polishedText: '금일 회의에서 논의된 신규 음성 인식 모델의 성능에 대한 요약 및 문서 작성을 요청드립니다.',
|
|
||||||
tokensUsed: 48,
|
|
||||||
costUsd: 0.0,
|
|
||||||
})
|
|
||||||
} else if (mode === 'meeting') {
|
|
||||||
setFinalResult({
|
|
||||||
status: 'success',
|
|
||||||
meetingTitle: 'D3RO Voice v0.2.1 릴리스 및 파이프라인 최적화 회의',
|
|
||||||
diarization: {
|
|
||||||
speaker1: '팀장 (45% 발화율)',
|
|
||||||
speaker2: 'ML 엔지니어 (55% 발화율)',
|
|
||||||
},
|
|
||||||
summary: 'Faster-Whisper turbo 사이드카 도입으로 지연 시간을 142ms로 6배 단축하였으며, Pyannote 화자 분리 정확도 96.4%를 달성함.',
|
|
||||||
actionItems: [
|
|
||||||
'1. Windows 및 macOS 배포 패키지 무결성 검증 완료',
|
|
||||||
'2. SQLite 벡터 RAG 인덱스 4.8k 문서 동기화',
|
|
||||||
],
|
|
||||||
generatedDocs: ['Executive Summary', 'Action Item Checklist', 'Mindmap Diagram'],
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
setFinalResult({
|
|
||||||
status: 'success',
|
|
||||||
query: searchQuery,
|
|
||||||
embeddingLatencyMs: 18.4,
|
|
||||||
vectorMatches: [
|
|
||||||
{ docId: 'DOC_4821', title: '2026 Q3 D3RO Voice 로드맵.md', similarity: 0.942, excerpt: 'Phase 15.5 화자 분리 및 실시간 회의 모드 8월 말 정식 출시...' },
|
|
||||||
{ docId: 'DOC_3102', title: 'Whisper_Turbo_사이드카_아키텍처.md', similarity: 0.887, excerpt: 'dual-condition parallel flush 패턴을 적용하여 버퍼 지연 최소화...' },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, 2100)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'space-between', alignItems: 'center', mb: 3, gap: 2 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
||||||
<StatRing color="blue" size={44}>
|
|
||||||
<svg width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</StatRing>
|
|
||||||
<Box>
|
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
|
||||||
Live Voice & AI Intelligence Sandbox
|
|
||||||
</Typography>
|
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
|
||||||
SIMULATE VOICE CAPTURE • INTERIM STT • AUTO-POLISH • VECTOR RAG
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Mode Selector Tabs */}
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, bgcolor: 'rgba(10, 17, 31, 0.8)', p: 0.5, borderRadius: '999px', border: `1px solid ${C.border}` }}>
|
|
||||||
{(['dictation', 'meeting', 'rag'] as const).map((m) => (
|
|
||||||
<Button
|
|
||||||
key={m}
|
|
||||||
size="small"
|
|
||||||
onClick={() => { setMode(m); setFinalResult(null); setInterimText('') }}
|
|
||||||
sx={{
|
|
||||||
borderRadius: '999px',
|
|
||||||
px: 2,
|
|
||||||
py: 0.4,
|
|
||||||
fontSize: '11px',
|
|
||||||
fontFamily: FONT_SANS,
|
|
||||||
fontWeight: mode === m ? 700 : 500,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.04em',
|
|
||||||
bgcolor: mode === m ? 'rgba(59, 130, 246, 0.25)' : 'transparent',
|
|
||||||
color: mode === m ? C.bright : C.dim,
|
|
||||||
border: mode === m ? '1px solid rgba(96, 165, 250, 0.4)' : '1px solid transparent',
|
|
||||||
'&:hover': { bgcolor: 'rgba(59, 130, 246, 0.15)', color: C.bright },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{m === 'dictation' ? '🎤 Dictation' : m === 'meeting' ? '👥 Meeting Mode' : '🔍 Vector RAG'}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Pipeline Progress Stages */}
|
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1.5, mb: 3 }}>
|
|
||||||
{steps.map((s, idx) => {
|
|
||||||
const stepNum = idx + 1
|
|
||||||
const isActive = step === stepNum
|
|
||||||
const isDone = step > stepNum
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={s.label}
|
|
||||||
sx={{
|
|
||||||
p: 1.5,
|
|
||||||
borderRadius: '12px',
|
|
||||||
bgcolor: isActive ? 'rgba(59, 130, 246, 0.18)' : isDone ? 'rgba(16, 185, 129, 0.12)' : 'rgba(10, 17, 31, 0.6)',
|
|
||||||
border: `1px solid ${isActive ? C.accentLight : isDone ? 'rgba(16, 185, 129, 0.3)' : C.border}`,
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: isActive ? C.cyanLight : isDone ? C.green400 : C.dim }}>
|
|
||||||
STAGE 0{stepNum}
|
|
||||||
</Typography>
|
|
||||||
{isDone ? (
|
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.green400 }}>✓ DONE</Typography>
|
|
||||||
) : isActive ? (
|
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.cyanLight, animation: 'pulse-ring 1s infinite' }}>● ACTIVE</Typography>
|
|
||||||
) : (
|
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.muted }}>READY</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', fontWeight: 600, color: C.bright }}>
|
|
||||||
{s.label}
|
|
||||||
</Typography>
|
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>
|
|
||||||
{s.desc}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Interactive Trigger Bar */}
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
|
||||||
{mode === 'rag' ? (
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder="Search vectorized knowledge documents..."
|
|
||||||
sx={{
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
bgcolor: 'rgba(10, 17, 31, 0.7)',
|
|
||||||
color: C.bright,
|
|
||||||
borderRadius: '10px',
|
|
||||||
fontFamily: FONT_SANS,
|
|
||||||
fontSize: '13px',
|
|
||||||
'& fieldset': { borderColor: C.border },
|
|
||||||
'&:hover fieldset': { borderColor: C.borderHl },
|
|
||||||
'&.Mui-focused fieldset': { borderColor: C.accentLight },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
p: 1.5,
|
|
||||||
borderRadius: '10px',
|
|
||||||
bgcolor: 'rgba(10, 17, 31, 0.7)',
|
|
||||||
border: `1px solid ${C.border}`,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: interimText ? C.cyanLight : C.dim }}>
|
|
||||||
{interimText || 'Waiting for voice audio input stream...'}
|
|
||||||
</Typography>
|
|
||||||
{isRunning && (
|
|
||||||
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
|
|
||||||
{[12, 24, 18, 28, 14, 20, 32, 16].map((h, i) => (
|
|
||||||
<Box
|
|
||||||
key={i}
|
|
||||||
sx={{
|
|
||||||
width: 3,
|
|
||||||
height: h,
|
|
||||||
bgcolor: C.cyanLight,
|
|
||||||
borderRadius: '2px',
|
|
||||||
animation: `pulse-ring ${0.4 + i * 0.1}s ease-in-out infinite alternate`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
disabled={isRunning}
|
|
||||||
onClick={runSimulation}
|
|
||||||
sx={{
|
|
||||||
background: 'linear-gradient(135deg, #1d4ed8 0%, #3b82f6 55%, #60a5fa 100%)',
|
|
||||||
color: '#ffffff',
|
|
||||||
px: 3,
|
|
||||||
py: 1.1,
|
|
||||||
borderRadius: '10px',
|
|
||||||
fontFamily: FONT_SANS,
|
|
||||||
fontSize: '13px',
|
|
||||||
fontWeight: 700,
|
|
||||||
textTransform: 'none',
|
|
||||||
flexShrink: 0,
|
|
||||||
boxShadow: '0 0 20px rgba(59, 130, 246, 0.4)',
|
|
||||||
'&:hover': { filter: 'brightness(1.15)' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isRunning ? 'Processing...' : '▶ Run Live Test'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Output Results Box */}
|
|
||||||
{finalResult && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
p: 2.5,
|
|
||||||
borderRadius: '14px',
|
|
||||||
bgcolor: 'rgba(10, 17, 31, 0.85)',
|
|
||||||
border: `1px solid rgba(59, 130, 246, 0.3)`,
|
|
||||||
boxShadow: 'inset 0 2px 6px rgba(3, 7, 18, 0.7)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', fontWeight: 600, color: C.green400 }}>
|
|
||||||
PIPELINE EXECUTION TELEMETRY RESULT
|
|
||||||
</Typography>
|
|
||||||
<TactileBadge tone="success" mono>
|
|
||||||
SUCCESS (200 OK)
|
|
||||||
</TactileBadge>
|
|
||||||
</Box>
|
|
||||||
<Box
|
|
||||||
component="pre"
|
|
||||||
sx={{
|
|
||||||
m: 0,
|
|
||||||
fontFamily: FONT_MONO,
|
|
||||||
fontSize: '12px',
|
|
||||||
color: C.bright,
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
lineHeight: 1.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{JSON.stringify(finalResult, null, 2)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</DoubleBezelCard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
// apps/admin/src/components/license-issuer-button.tsx
|
// apps/admin/src/components/license-issuer-button.tsx
|
||||||
// D3RO Voice Admin — Ed25519 라이선스 발급 트리거 버튼
|
// D3RO Voice Admin — Ed25519 라이선스 발급 트리거 버튼 (super_admin 전용)
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Button } from '@mui/material'
|
import { Button } from '@mui/material'
|
||||||
import { primaryButtonSx } from '@/lib/console-theme'
|
import { C, primaryButtonSx } from '@/lib/console-theme'
|
||||||
import { LicenseIssuerDialog } from './license-issuer-dialog'
|
import { LicenseIssuerDialog } from './license-issuer-dialog'
|
||||||
|
|
||||||
export function LicenseIssuerButton(): React.ReactElement {
|
export function LicenseIssuerButton(): React.ReactElement {
|
||||||
|
|
@ -19,8 +19,8 @@ export function LicenseIssuerButton(): React.ReactElement {
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
sx={{
|
sx={{
|
||||||
...primaryButtonSx,
|
...primaryButtonSx,
|
||||||
background: 'linear-gradient(135deg, #a855f7 0%, #3b82f6 100%)',
|
background: `linear-gradient(135deg, ${C.purple} 0%, ${C.accent} 100%)`,
|
||||||
boxShadow: '0 0 12px rgba(168, 85, 247, 0.4)',
|
boxShadow: '0 0 12px rgba(139, 92, 246, 0.4)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
⚡ Issue Ed25519 License Key
|
⚡ Issue Ed25519 License Key
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
// apps/admin/src/components/license-issuer-dialog.tsx
|
// apps/admin/src/components/license-issuer-dialog.tsx
|
||||||
// D3RO Voice Admin — Ed25519 비대칭 암호화 라이선스 발급 다이얼로그
|
// Ed25519 라이선스 발급 다이얼로그 — 서명은 서버(/api/admin/license)에서만 수행한다.
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
|
|
@ -17,12 +17,8 @@ import {
|
||||||
Select,
|
Select,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Button,
|
Button,
|
||||||
Alert,
|
Alert
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import {
|
|
||||||
issueSignedLicenseKey,
|
|
||||||
DEFAULT_LICENSE_PRIVATE_KEY,
|
|
||||||
} from '@d3ro/core/utils/crypto-license'
|
|
||||||
import type { LicenseTier } from '@d3ro/core/types'
|
import type { LicenseTier } from '@d3ro/core/types'
|
||||||
import { d3roPalette, d3roFontMono, d3roRadius } from '@d3ro/ui/theme'
|
import { d3roPalette, d3roFontMono, d3roRadius } from '@d3ro/ui/theme'
|
||||||
import { C, FONT_SANS, FONT_MONO, primaryButtonSx } from '@/lib/console-theme'
|
import { C, FONT_SANS, FONT_MONO, primaryButtonSx } from '@/lib/console-theme'
|
||||||
|
|
@ -32,6 +28,16 @@ interface LicenseIssuerDialogProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function describeIssueError(errorKey: unknown): string {
|
||||||
|
if (errorKey === 'license_signing_unavailable') {
|
||||||
|
return '서명 키가 구성되지 않았습니다. ADMIN_LICENSE_PRIVATE_KEY 환경변수를 설정해주세요.'
|
||||||
|
}
|
||||||
|
if (errorKey === 'admin_forbidden') return 'super_admin 권한이 필요한 작업입니다.'
|
||||||
|
if (errorKey === 'admin_session_invalid') return '세션이 만료되었습니다. 다시 로그인해주세요.'
|
||||||
|
if (errorKey === 'invalid_customer_email') return '고객 이메일 형식이 올바르지 않습니다.'
|
||||||
|
return '라이선스 발급에 실패했습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps): React.ReactElement {
|
export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps): React.ReactElement {
|
||||||
const [customerEmail, setCustomerEmail] = useState('')
|
const [customerEmail, setCustomerEmail] = useState('')
|
||||||
const [tier, setTier] = useState<LicenseTier>('pro_plus')
|
const [tier, setTier] = useState<LicenseTier>('pro_plus')
|
||||||
|
|
@ -39,10 +45,13 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
|
||||||
const [machineId, setMachineId] = useState('')
|
const [machineId, setMachineId] = useState('')
|
||||||
const [teamId, setTeamId] = useState('')
|
const [teamId, setTeamId] = useState('')
|
||||||
const [generatedKey, setGeneratedKey] = useState<string | null>(null)
|
const [generatedKey, setGeneratedKey] = useState<string | null>(null)
|
||||||
|
const [usedDefaultKey, setUsedDefaultKey] = useState(false)
|
||||||
|
const [auditRecorded, setAuditRecorded] = useState(true)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleGenerate = () => {
|
const handleGenerate = async (): Promise<void> => {
|
||||||
setError(null)
|
setError(null)
|
||||||
setCopied(false)
|
setCopied(false)
|
||||||
if (!customerEmail.trim()) {
|
if (!customerEmail.trim()) {
|
||||||
|
|
@ -50,36 +59,36 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const now = Date.now()
|
const response = await fetch('/api/admin/license', {
|
||||||
let expiresAt: number | null = null
|
method: 'POST',
|
||||||
if (validity === '30d') {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
expiresAt = now + 30 * 24 * 60 * 60 * 1000
|
body: JSON.stringify({
|
||||||
} else if (validity === '365d') {
|
customerEmail: customerEmail.trim(),
|
||||||
expiresAt = now + 365 * 24 * 60 * 60 * 1000
|
tier,
|
||||||
|
validity,
|
||||||
|
machineId: machineId.trim(),
|
||||||
|
teamId: teamId.trim()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const data = (await response.json()) as Record<string, unknown>
|
||||||
|
if (!response.ok || data.success !== true || typeof data.licenseKey !== 'string') {
|
||||||
|
throw new Error(describeIssueError(data.error))
|
||||||
}
|
}
|
||||||
|
setGeneratedKey(data.licenseKey)
|
||||||
const payload = {
|
setUsedDefaultKey(data.usedDefaultKey === true)
|
||||||
licenseId: `lic-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 6)}`,
|
setAuditRecorded(data.auditRecorded !== false)
|
||||||
tier,
|
|
||||||
customerEmail: customerEmail.trim(),
|
|
||||||
issuedAt: now,
|
|
||||||
expiresAt,
|
|
||||||
machineId: machineId.trim() || null,
|
|
||||||
teamId: teamId.trim() || undefined,
|
|
||||||
maxDevices: tier === 'enterprise' ? 999 : tier === 'team' ? 25 : tier === 'pro_plus' ? 5 : 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = issueSignedLicenseKey(payload, DEFAULT_LICENSE_PRIVATE_KEY)
|
|
||||||
setGeneratedKey(key)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to generate license key')
|
setError(err instanceof Error ? err.message : 'Failed to generate license key')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = (): void => {
|
||||||
if (!generatedKey) return
|
if (!generatedKey) return
|
||||||
navigator.clipboard.writeText(generatedKey)
|
void navigator.clipboard.writeText(generatedKey)
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
setTimeout(() => setCopied(false), 3000)
|
setTimeout(() => setCopied(false), 3000)
|
||||||
}
|
}
|
||||||
|
|
@ -90,13 +99,13 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: d3roPalette.text.primary,
|
color: d3roPalette.text.primary,
|
||||||
bgcolor: d3roPalette.bg.inset,
|
bgcolor: d3roPalette.bg.inset,
|
||||||
borderRadius: d3roRadius.button,
|
borderRadius: d3roRadius.button
|
||||||
},
|
},
|
||||||
'& .MuiInputLabel-root': {
|
'& .MuiInputLabel-root': {
|
||||||
fontFamily: FONT_MONO,
|
fontFamily: FONT_MONO,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: C.dim,
|
color: C.dim
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -107,21 +116,21 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
|
||||||
fullWidth
|
fullWidth
|
||||||
PaperProps={{
|
PaperProps={{
|
||||||
sx: {
|
sx: {
|
||||||
bgcolor: '#0a0d14',
|
bgcolor: C.app,
|
||||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
border: `1px solid ${C.borderHl}`,
|
||||||
borderRadius: '16px',
|
borderRadius: '16px',
|
||||||
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.8)',
|
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.8)',
|
||||||
p: 1,
|
p: 1
|
||||||
},
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', pb: 1 }}>
|
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', pb: 1 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '18px', fontWeight: 700, color: C.bright }}>
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '18px', fontWeight: 600, color: C.bright }}>
|
||||||
Issue Cryptographic License Key
|
Issue Cryptographic License Key
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||||
ED25519 ASYMMETRIC SIGNED OFFLINE / ENTERPRISE TOKEN
|
ED25519 SERVER-SIGNED OFFLINE / ENTERPRISE TOKEN
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton onClick={onClose} size="small" sx={{ color: C.dim }}>
|
<IconButton onClick={onClose} size="small" sx={{ color: C.dim }}>
|
||||||
|
|
@ -152,7 +161,11 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
|
||||||
|
|
||||||
<FormControl fullWidth sx={inputSx}>
|
<FormControl fullWidth sx={inputSx}>
|
||||||
<InputLabel>Validity Period</InputLabel>
|
<InputLabel>Validity Period</InputLabel>
|
||||||
<Select value={validity} onChange={(e) => setValidity(e.target.value as '30d' | '365d' | 'lifetime')} label="Validity Period">
|
<Select
|
||||||
|
value={validity}
|
||||||
|
onChange={(e) => setValidity(e.target.value as '30d' | '365d' | 'lifetime')}
|
||||||
|
label="Validity Period"
|
||||||
|
>
|
||||||
<MenuItem value="30d">30 Days (Monthly)</MenuItem>
|
<MenuItem value="30d">30 Days (Monthly)</MenuItem>
|
||||||
<MenuItem value="365d">1 Year (Annual)</MenuItem>
|
<MenuItem value="365d">1 Year (Annual)</MenuItem>
|
||||||
<MenuItem value="lifetime">Lifetime (Permanent)</MenuItem>
|
<MenuItem value="lifetime">Lifetime (Permanent)</MenuItem>
|
||||||
|
|
@ -184,20 +197,32 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleGenerate}
|
onClick={() => void handleGenerate()}
|
||||||
|
disabled={loading}
|
||||||
sx={{
|
sx={{
|
||||||
...primaryButtonSx,
|
...primaryButtonSx,
|
||||||
py: 1.2,
|
py: 1.2,
|
||||||
background: 'linear-gradient(135deg, #a855f7 0%, #3b82f6 100%)',
|
background: `linear-gradient(135deg, ${C.purple} 0%, ${C.accent} 100%)`,
|
||||||
fontWeight: 700,
|
fontWeight: 600
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
⚡ Generate Ed25519 Signed License
|
{loading ? 'Signing…' : '⚡ Generate Ed25519 Signed License'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{generatedKey && (
|
{generatedKey && (
|
||||||
<Box sx={{ mt: 1, p: 2, bgcolor: 'rgba(0, 0, 0, 0.4)', borderRadius: '10px', border: '1px solid rgba(168, 85, 247, 0.4)' }}>
|
<Box sx={{ mt: 1, p: 2, bgcolor: 'rgba(0, 0, 0, 0.4)', borderRadius: '10px', border: `1px solid ${C.borderStrong}` }}>
|
||||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: '#a855f7', fontWeight: 700, mb: 0.5 }}>
|
{usedDefaultKey && (
|
||||||
|
<Alert severity="warning" sx={{ fontFamily: FONT_MONO, fontSize: '11px', mb: 1.5 }}>
|
||||||
|
저장소 기본 키로 서명되었습니다. 기본 키쌍은 공개되어 위조 방어력이 없으므로 운영 배포 전
|
||||||
|
ADMIN_LICENSE_PRIVATE_KEY로 키를 로테이션하세요.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{!auditRecorded && (
|
||||||
|
<Alert severity="info" sx={{ fontFamily: FONT_MONO, fontSize: '11px', mb: 1.5 }}>
|
||||||
|
라이선스는 발급되었으나 감사 로그 기록에 실패했습니다. 백엔드 연결을 확인하세요.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.purple400, fontWeight: 600, mb: 0.5 }}>
|
||||||
SIGNED LICENSE KEY (Copy and paste into D3RO Voice Desktop App):
|
SIGNED LICENSE KEY (Copy and paste into D3RO Voice Desktop App):
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography
|
||||||
|
|
@ -210,7 +235,7 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
wordBreak: 'break-all',
|
wordBreak: 'break-all',
|
||||||
userSelect: 'all',
|
userSelect: 'all',
|
||||||
mb: 1.5,
|
mb: 1.5
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{generatedKey}
|
{generatedKey}
|
||||||
|
|
@ -222,8 +247,8 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: FONT_MONO,
|
fontFamily: FONT_MONO,
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
borderColor: copied ? '#10b981' : '#a855f7',
|
borderColor: copied ? C.green : C.purple400,
|
||||||
color: copied ? '#10b981' : C.bright,
|
color: copied ? C.green400 : C.bright
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{copied ? '✓ Copied to Clipboard!' : '📋 Copy License Key'}
|
{copied ? '✓ Copied to Clipboard!' : '📋 Copy License Key'}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
// 결제 이력 패널 — DB + Payple 조회
|
// 결제 이력 패널 — DB + Payple 조회
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Box, Button, CircularProgress } from '@mui/material'
|
import { Box, CircularProgress } from '@mui/material'
|
||||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||||
import { callAdminApi } from '@/lib/admin-api'
|
import { callAdminApi } from '@/lib/admin-api'
|
||||||
|
|
@ -25,22 +25,23 @@ interface AuditLogEntry {
|
||||||
interface PaymentData {
|
interface PaymentData {
|
||||||
subscription: Record<string, unknown> | null
|
subscription: Record<string, unknown> | null
|
||||||
auditLogs: AuditLogEntry[]
|
auditLogs: AuditLogEntry[]
|
||||||
paypleHistory?: Record<string, unknown>
|
providerEvents: Array<Record<string, unknown>>
|
||||||
paypleError?: string
|
providerOperations: Array<Record<string, unknown>>
|
||||||
|
liveProviderHistoryAvailable: false
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaymentHistory({ userId }: PaymentHistoryProps): React.ReactElement {
|
export function PaymentHistory({ userId }: PaymentHistoryProps): React.ReactElement {
|
||||||
const [data, setData] = useState<PaymentData | null>(null)
|
const [data, setData] = useState<PaymentData | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [paypleLoading, setPaypleLoading] = useState(false)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async (): Promise<void> => {
|
const load = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const result = await callAdminApi<PaymentData>(`admin-payments?userId=${userId}`)
|
const result = await callAdminApi<PaymentData>(`admin-payments?userId=${userId}`)
|
||||||
setData(result)
|
setData(result)
|
||||||
} catch {
|
} catch (caught) {
|
||||||
// ignore
|
setError(caught instanceof Error ? caught.message : 'Failed to load payment data')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -48,18 +49,6 @@ export function PaymentHistory({ userId }: PaymentHistoryProps): React.ReactElem
|
||||||
void load()
|
void load()
|
||||||
}, [userId])
|
}, [userId])
|
||||||
|
|
||||||
const loadPayple = async (): Promise<void> => {
|
|
||||||
setPaypleLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await callAdminApi<PaymentData>(`admin-payments?userId=${userId}&source=payple`)
|
|
||||||
setData(result)
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setPaypleLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
|
@ -69,55 +58,56 @@ export function PaymentHistory({ userId }: PaymentHistoryProps): React.ReactElem
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <PhosphorText variant="dim">Failed to load payment data</PhosphorText>
|
return <PhosphorText variant="dim" sx={{ color: d3roPalette.tag.red }}>{error ?? 'Failed to load payment data'}</PhosphorText>
|
||||||
}
|
}
|
||||||
|
|
||||||
const sub = data.subscription
|
const sub = data.subscription
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{error && <PhosphorText variant="dim" sx={{ color: d3roPalette.tag.red }}>{error}</PhosphorText>}
|
||||||
{/* Subscription summary */}
|
{/* Subscription summary */}
|
||||||
{sub && (
|
{sub && (
|
||||||
<MetalCard>
|
<MetalCard>
|
||||||
<Box sx={{ p: 1 }}>
|
<Box sx={{ p: 1 }}>
|
||||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PAYMENT INFO</PhosphorText>
|
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PAYMENT INFO</PhosphorText>
|
||||||
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
<Row label="PROVIDER" value={((sub.payment_provider as string) ?? 'none').toUpperCase()} />
|
<Row label="PROVIDER" value={String(sub.provider ?? sub.payment_provider).toUpperCase()} />
|
||||||
<Row label="PAYPLE PAYER ID" value={(sub.payple_payer_id as string) ?? '-'} />
|
<Row label="TIER" value={String(sub.tier).toUpperCase()} />
|
||||||
<Row label="PAYPLE OID" value={(sub.payple_pay_oid as string) ?? '-'} />
|
<Row label="STATUS" value={String(sub.status).toUpperCase()} />
|
||||||
<Row label="RENEWAL FAILURES" value={String(sub.renewal_failures ?? 0)} />
|
<Row label="CURRENT PERIOD END" value={typeof sub.current_period_end === 'string' ? new Date(sub.current_period_end).toLocaleString() : 'Not set'} />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</MetalCard>
|
</MetalCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Payple direct query */}
|
{/* Provider ledger. Provider payloads and secret identifiers are intentionally excluded. */}
|
||||||
<MetalCard>
|
<MetalCard>
|
||||||
<Box sx={{ p: 1 }}>
|
<Box sx={{ p: 1 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PAYMENT PROVIDER EVENT LEDGER</PhosphorText>
|
||||||
<PhosphorText variant="label">PAYPLE HISTORY</PhosphorText>
|
{data.providerEvents.length === 0 ? (
|
||||||
<Button
|
<PhosphorText variant="dim">No provider events recorded</PhosphorText>
|
||||||
size="small"
|
) : data.providerEvents.map((event) => (
|
||||||
onClick={() => void loadPayple()}
|
<Box key={String(event.id)} sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, py: 0.75, borderBottom: `1px solid ${d3roPalette.border.subtle}` }}>
|
||||||
disabled={paypleLoading}
|
{String(event.provider).toUpperCase()} · {String(event.event_type)} · {String(event.disposition).toUpperCase()} · {new Date(String(event.event_created_at)).toLocaleString()}
|
||||||
sx={{ fontFamily: d3roFontMono, fontSize: 11 }}
|
|
||||||
>
|
|
||||||
{paypleLoading ? 'Loading...' : 'Fetch from Payple'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{data.paypleHistory ? (
|
|
||||||
<Box sx={{
|
|
||||||
fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
|
|
||||||
bgcolor: d3roPalette.bg.inset, borderRadius: 1, p: 1, maxHeight: 300, overflow: 'auto',
|
|
||||||
whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: d3roPalette.text.primary,
|
|
||||||
}}>
|
|
||||||
{JSON.stringify(data.paypleHistory, null, 2)}
|
|
||||||
</Box>
|
</Box>
|
||||||
) : data.paypleError ? (
|
))}
|
||||||
<PhosphorText variant="dim" sx={{ color: d3roPalette.tag.red }}>{data.paypleError}</PhosphorText>
|
<PhosphorText variant="dim" sx={{ display: 'block', mt: 1 }}>
|
||||||
) : (
|
Live provider lookup is not connected. Raw Payple responses and provider identifiers are never exposed here.
|
||||||
<PhosphorText variant="dim">Click "Fetch from Payple" to query payment history</PhosphorText>
|
</PhosphorText>
|
||||||
)}
|
</Box>
|
||||||
|
</MetalCard>
|
||||||
|
|
||||||
|
<MetalCard>
|
||||||
|
<Box sx={{ p: 1 }}>
|
||||||
|
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PAYMENT OPERATIONS</PhosphorText>
|
||||||
|
{data.providerOperations.length === 0 ? (
|
||||||
|
<PhosphorText variant="dim">No payment operations recorded</PhosphorText>
|
||||||
|
) : data.providerOperations.map((operation) => (
|
||||||
|
<Box key={String(operation.id)} sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, py: 0.75, borderBottom: `1px solid ${d3roPalette.border.subtle}` }}>
|
||||||
|
{String(operation.provider).toUpperCase()} · {String(operation.operation_type)} · {String(operation.state).toUpperCase()} · {new Date(String(operation.created_at)).toLocaleString()}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</MetalCard>
|
</MetalCard>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,8 +77,10 @@ export function RoleChangeDialog({
|
||||||
</PhosphorText>
|
</PhosphorText>
|
||||||
|
|
||||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||||
<InputLabel sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.label }}>New Role</InputLabel>
|
<InputLabel id="role-change-new-role-label" sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.label }}>New Role</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
|
id="role-change-new-role"
|
||||||
|
labelId="role-change-new-role-label"
|
||||||
value={newRole}
|
value={newRole}
|
||||||
onChange={(e) => setNewRole(e.target.value as Role)}
|
onChange={(e) => setNewRole(e.target.value as Role)}
|
||||||
label="New Role"
|
label="New Role"
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||||
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
|
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
|
||||||
import { callAdminApi } from '@/lib/admin-api'
|
import { callAdminApi } from '@/lib/admin-api'
|
||||||
|
|
||||||
type Tier = 'free' | 'pro' | 'pro_plus' | 'team' | 'enterprise'
|
type Tier = 'free' | 'pro' | 'pro_plus'
|
||||||
type SubStatus = 'active' | 'canceled' | 'past_due' | 'expired'
|
type SubStatus = 'active' | 'canceled' | 'past_due' | 'expired'
|
||||||
|
|
||||||
interface SubscriptionFormProps {
|
interface SubscriptionFormProps {
|
||||||
|
|
@ -104,19 +104,17 @@ export function SubscriptionForm({ mode, userId, initial, onSuccess }: Subscript
|
||||||
</PhosphorText>
|
</PhosphorText>
|
||||||
|
|
||||||
<FormControl fullWidth sx={inputSx}>
|
<FormControl fullWidth sx={inputSx}>
|
||||||
<InputLabel sx={{ fontFamily: d3roFontMono }}>Tier</InputLabel>
|
<InputLabel id="subscription-tier-label" sx={{ fontFamily: d3roFontMono }}>Tier</InputLabel>
|
||||||
<Select value={tier} onChange={(e) => setTier(e.target.value as Tier)} label="Tier">
|
<Select id="subscription-tier" labelId="subscription-tier-label" value={tier} onChange={(e) => setTier(e.target.value as Tier)} label="Tier">
|
||||||
<MenuItem value="free">FREE</MenuItem>
|
<MenuItem value="free">FREE</MenuItem>
|
||||||
<MenuItem value="pro">PRO</MenuItem>
|
<MenuItem value="pro">PRO</MenuItem>
|
||||||
<MenuItem value="pro_plus">PRO+</MenuItem>
|
<MenuItem value="pro_plus">PRO+</MenuItem>
|
||||||
<MenuItem value="team">TEAM</MenuItem>
|
|
||||||
<MenuItem value="enterprise">ENTERPRISE</MenuItem>
|
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormControl fullWidth sx={inputSx}>
|
<FormControl fullWidth sx={inputSx}>
|
||||||
<InputLabel sx={{ fontFamily: d3roFontMono }}>Status</InputLabel>
|
<InputLabel id="subscription-status-label" sx={{ fontFamily: d3roFontMono }}>Status</InputLabel>
|
||||||
<Select value={status} onChange={(e) => setStatus(e.target.value as SubStatus)} label="Status">
|
<Select id="subscription-status" labelId="subscription-status-label" value={status} onChange={(e) => setStatus(e.target.value as SubStatus)} label="Status">
|
||||||
<MenuItem value="active">ACTIVE</MenuItem>
|
<MenuItem value="active">ACTIVE</MenuItem>
|
||||||
<MenuItem value="canceled">CANCELED</MenuItem>
|
<MenuItem value="canceled">CANCELED</MenuItem>
|
||||||
<MenuItem value="past_due">PAST DUE</MenuItem>
|
<MenuItem value="past_due">PAST DUE</MenuItem>
|
||||||
|
|
|
||||||
37
apps/admin/src/components/unavailable-admin-panel.tsx
Normal file
37
apps/admin/src/components/unavailable-admin-panel.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { Alert, Box, Typography } from '@mui/material'
|
||||||
|
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||||
|
import { C, FONT_MONO, FONT_SANS, panelSx } from '@/lib/console-theme'
|
||||||
|
|
||||||
|
interface UnavailableAdminPanelProps {
|
||||||
|
title: string
|
||||||
|
capability: string
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnavailableAdminPanel({ title, capability, reason }: UnavailableAdminPanelProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<Box sx={{ ...panelSx, minHeight: 84, px: { xs: 2.5, md: 4 }, py: 2, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Box sx={{ width: 4, height: 32, borderRadius: '999px', bgcolor: C.orange400 }} />
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Typography component="h1" sx={{ fontFamily: FONT_SANS, fontSize: '20px', fontWeight: 500, color: C.bright }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<TactileBadge tone="warning" mono>NOT CONNECTED</TactileBadge>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, mt: 0.25 }}>
|
||||||
|
{capability}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||||
|
<Alert severity="warning">
|
||||||
|
<Typography sx={{ fontWeight: 500, mb: 0.5 }}>실제 관리 계약이 아직 연결되지 않았습니다.</Typography>
|
||||||
|
<Typography>{reason}</Typography>
|
||||||
|
<Typography sx={{ mt: 1 }}>샘플 수치나 성공 상태는 표시하지 않으며, 쓰기 제어도 비활성화했습니다.</Typography>
|
||||||
|
</Alert>
|
||||||
|
</DoubleBezelCard>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
apps/admin/src/instrumentation.ts
Normal file
14
apps/admin/src/instrumentation.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { requireAdminSessionSecret } from '@/lib/admin-session'
|
||||||
|
|
||||||
|
export async function register(): Promise<void> {
|
||||||
|
if (process.env.NEXT_RUNTIME !== 'nodejs') return
|
||||||
|
|
||||||
|
requireAdminSessionSecret()
|
||||||
|
const apiServerUrl = process.env.API_SERVER_URL?.trim()
|
||||||
|
if (!apiServerUrl) throw new Error('API_SERVER_URL is required')
|
||||||
|
|
||||||
|
const parsed = new URL(apiServerUrl)
|
||||||
|
if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') {
|
||||||
|
throw new Error('API_SERVER_URL must use HTTPS in production')
|
||||||
|
}
|
||||||
|
}
|
||||||
98
apps/admin/src/lib/ad-monetization.ts
Normal file
98
apps/admin/src/lib/ad-monetization.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import 'server-only'
|
||||||
|
|
||||||
|
import type { AdFormat, AdNetworkId } from '@d3ro/core/types'
|
||||||
|
import { getSupabaseAdminClient } from './supabase-admin'
|
||||||
|
|
||||||
|
export type AdIntegrationStatus = 'fail_closed'
|
||||||
|
|
||||||
|
export interface MediationNetworkEntry {
|
||||||
|
networkId: AdNetworkId
|
||||||
|
name: string
|
||||||
|
formats: AdFormat[]
|
||||||
|
floorEcpm: number
|
||||||
|
integration: AdIntegrationStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// apps/desktop AdMediationEngine 어댑터 등록부의 미러 — 데스크톱에서 어댑터를
|
||||||
|
// 추가/변경하면 이 로스터도 함께 갱신해야 한다. 모든 어댑터는 현재 fail-closed
|
||||||
|
// (bid 없음, 리워드 미지급) 상태이므로 통합 상태를 실측 없이 live로 표기하지 않는다.
|
||||||
|
export const MEDIATION_ROSTER: MediationNetworkEntry[] = [
|
||||||
|
{ networkId: 'direct_sponsor', name: 'Direct House Sponsor', formats: ['banner_dock', 'rewarded_video', 'export_sponsor'], floorEcpm: 12, integration: 'fail_closed' },
|
||||||
|
{ networkId: 'playwire', name: 'Playwire RAMP', formats: ['banner_dock', 'rewarded_video', 'export_sponsor'], floorEcpm: 4.5, integration: 'fail_closed' },
|
||||||
|
{ networkId: 'ethical_ads', name: 'EthicalAds', formats: ['banner_dock', 'export_sponsor'], floorEcpm: 3.2, integration: 'fail_closed' },
|
||||||
|
{ networkId: 'carbon_ads', name: 'Carbon Ads', formats: ['banner_dock'], floorEcpm: 3.5, integration: 'fail_closed' },
|
||||||
|
{ networkId: 'unity_ads', name: 'Unity LevelPlay', formats: ['rewarded_video', 'banner_dock'], floorEcpm: 6, integration: 'fail_closed' },
|
||||||
|
{ networkId: 'applovin_max', name: 'AppLovin MAX', formats: ['rewarded_video', 'banner_dock', 'export_sponsor'], floorEcpm: 5.5, integration: 'fail_closed' },
|
||||||
|
{ networkId: 'google_ad_manager', name: 'Google Ad Manager', formats: ['banner_dock', 'rewarded_video', 'export_sponsor'], floorEcpm: 2, integration: 'fail_closed' },
|
||||||
|
{ networkId: 'inmobi', name: 'InMobi', formats: ['banner_dock', 'rewarded_video'], floorEcpm: 2.8, integration: 'fail_closed' },
|
||||||
|
{ networkId: 'pubmatic', name: 'PubMatic OpenWrap', formats: ['banner_dock', 'export_sponsor'], floorEcpm: 3, integration: 'fail_closed' },
|
||||||
|
{ networkId: 'mintegral', name: 'Mintegral', formats: ['rewarded_video', 'banner_dock'], floorEcpm: 4, integration: 'fail_closed' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface AdRewardNetworkSummary {
|
||||||
|
network: string
|
||||||
|
claims: number
|
||||||
|
rewardTokens: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdRewardClaimRow {
|
||||||
|
id: string
|
||||||
|
network: string
|
||||||
|
placement: string
|
||||||
|
rewardTokens: number
|
||||||
|
verifiedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdRewardStats {
|
||||||
|
windowDays: number
|
||||||
|
totalClaims: number
|
||||||
|
totalRewardTokens: number
|
||||||
|
uniqueClaimants: number
|
||||||
|
byNetwork: AdRewardNetworkSummary[]
|
||||||
|
recentClaims: AdRewardClaimRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdRewardStats(windowDays = 30): Promise<AdRewardStats> {
|
||||||
|
const supabase = await getSupabaseAdminClient('manager')
|
||||||
|
const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1_000).toISOString()
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('ad_reward_claims')
|
||||||
|
.select('id, user_id, network, placement, reward_tokens, verified_at')
|
||||||
|
.gte('verified_at', since)
|
||||||
|
.order('verified_at', { ascending: false })
|
||||||
|
.limit(2_000)
|
||||||
|
if (error) throw new Error('Supabase ad reward claims query failed')
|
||||||
|
|
||||||
|
const rows = (data ?? []).filter(
|
||||||
|
(row): row is { id: string; user_id: string; network: string; placement: string; reward_tokens: number; verified_at: string } =>
|
||||||
|
typeof row?.id === 'string' &&
|
||||||
|
typeof row?.user_id === 'string' &&
|
||||||
|
typeof row?.network === 'string' &&
|
||||||
|
typeof row?.reward_tokens === 'number' &&
|
||||||
|
typeof row?.verified_at === 'string'
|
||||||
|
)
|
||||||
|
|
||||||
|
const byNetworkMap = new Map<string, AdRewardNetworkSummary>()
|
||||||
|
for (const row of rows) {
|
||||||
|
const summary = byNetworkMap.get(row.network) ?? { network: row.network, claims: 0, rewardTokens: 0 }
|
||||||
|
summary.claims += 1
|
||||||
|
summary.rewardTokens += row.reward_tokens
|
||||||
|
byNetworkMap.set(row.network, summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
windowDays,
|
||||||
|
totalClaims: rows.length,
|
||||||
|
totalRewardTokens: rows.reduce((sum, row) => sum + row.reward_tokens, 0),
|
||||||
|
uniqueClaimants: new Set(rows.map((row) => row.user_id)).size,
|
||||||
|
byNetwork: [...byNetworkMap.values()].sort((a, b) => b.rewardTokens - a.rewardTokens),
|
||||||
|
recentClaims: rows.slice(0, 20).map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
network: row.network,
|
||||||
|
placement: typeof row.placement === 'string' ? row.placement : '—',
|
||||||
|
rewardTokens: row.reward_tokens,
|
||||||
|
verifiedAt: row.verified_at
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
// apps/admin/src/lib/admin-api.ts
|
'use client'
|
||||||
// Edge Function 호출 헬퍼 — 클라이언트 컴포넌트용
|
|
||||||
|
|
||||||
import { getSupabaseBrowserClient } from './supabase-browser'
|
|
||||||
|
|
||||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
|
|
||||||
|
|
||||||
interface AdminApiOptions extends Omit<RequestInit, 'headers'> {
|
interface AdminApiOptions extends Omit<RequestInit, 'headers'> {
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
|
|
@ -13,33 +8,25 @@ export async function callAdminApi<T = Record<string, unknown>>(
|
||||||
path: string,
|
path: string,
|
||||||
options: AdminApiOptions = {}
|
options: AdminApiOptions = {}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const supabase = getSupabaseBrowserClient()
|
const [operation, query = ''] = path.split('?', 2)
|
||||||
|
if (!/^admin-[a-z-]+$/.test(operation)) throw new Error('Invalid admin operation')
|
||||||
// getUser()로 토큰 갱신을 트리거한 뒤 세션에서 access_token 획득
|
const method = (options.method ?? 'GET').toUpperCase()
|
||||||
const { data: { user }, error: userError } = await supabase.auth.getUser()
|
const headers: Record<string, string> = {
|
||||||
if (userError || !user) {
|
Accept: 'application/json',
|
||||||
throw new Error('Not authenticated — please re-login')
|
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
if (method !== 'GET' && !headers['Idempotency-Key']) {
|
||||||
|
headers['Idempotency-Key'] = crypto.randomUUID()
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: { session } } = await supabase.auth.getSession()
|
const response = await fetch(`/api/admin/supabase/${operation}${query ? `?${query}` : ''}`, {
|
||||||
if (!session?.access_token) {
|
|
||||||
throw new Error('Not authenticated — session expired')
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${SUPABASE_URL}/functions/v1/${path}`, {
|
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
method,
|
||||||
Authorization: `Bearer ${session.access_token}`,
|
headers,
|
||||||
'Content-Type': 'application/json',
|
cache: 'no-store'
|
||||||
...options.headers,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
const payload = await response.json().catch(() => null) as T & { error?: string }
|
||||||
const data = await response.json() as T & { error?: string }
|
if (!response.ok) throw new Error(payload?.error ?? `Admin API error (${response.status})`)
|
||||||
|
return payload
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.error ?? `API error: ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@
|
||||||
|
|
||||||
import { cookies } from 'next/headers'
|
import { cookies } from 'next/headers'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
|
import { verifySession } from './security'
|
||||||
|
import type { AdminRole } from './admin-session'
|
||||||
|
|
||||||
export type AdminRole = 'manager' | 'admin' | 'super_admin'
|
export type { AdminRole } from './admin-session'
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -22,21 +24,17 @@ export async function requireManager(): Promise<AdminUser> {
|
||||||
redirect('/login')
|
redirect('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const session = verifySession(sessionCookie)
|
||||||
const decoded = JSON.parse(Buffer.from(sessionCookie, 'base64').toString('utf-8'))
|
if (!session) {
|
||||||
if (!decoded || !decoded.expiresAt || decoded.expiresAt <= Date.now()) {
|
|
||||||
redirect('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: decoded.id || 'admin-usr-1',
|
|
||||||
email: decoded.email || 'admin@d3ro.voice',
|
|
||||||
name: decoded.username === 'admin' ? 'Master Admin' : decoded.email,
|
|
||||||
role: (decoded.role as AdminRole) || 'super_admin',
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
redirect('/login')
|
redirect('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: session.email,
|
||||||
|
email: session.email,
|
||||||
|
name: session.email,
|
||||||
|
role: session.role
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** admin 이상 */
|
/** admin 이상 */
|
||||||
|
|
|
||||||
72
apps/admin/src/lib/admin-session.ts
Normal file
72
apps/admin/src/lib/admin-session.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
export type AdminRole = 'manager' | 'admin' | 'super_admin'
|
||||||
|
|
||||||
|
export interface AdminSession {
|
||||||
|
email: string
|
||||||
|
role: AdminRole
|
||||||
|
token: string
|
||||||
|
loginAt: string
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/
|
||||||
|
|
||||||
|
export function requireAdminSessionSecret(): string {
|
||||||
|
const value = process.env.ADMIN_SESSION_SECRET?.trim()
|
||||||
|
if (!value || new TextEncoder().encode(value).byteLength < 32) {
|
||||||
|
throw new Error('ADMIN_SESSION_SECRET must contain at least 32 bytes')
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAdminSession(value: unknown, now = Date.now()): AdminSession | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||||
|
|
||||||
|
const candidate = value as Record<string, unknown>
|
||||||
|
const email = typeof candidate.email === 'string' ? candidate.email.trim().toLowerCase() : ''
|
||||||
|
const role = candidate.role
|
||||||
|
const token = candidate.token
|
||||||
|
const loginAt = candidate.loginAt
|
||||||
|
const expiresAt = candidate.expiresAt
|
||||||
|
|
||||||
|
if (!email || email.length > 150 || !email.includes('@')) return null
|
||||||
|
if (role !== 'manager' && role !== 'admin' && role !== 'super_admin') return null
|
||||||
|
if (typeof token !== 'string' || token.length < 80 || token.length > 8_192) return null
|
||||||
|
if (typeof loginAt !== 'string' || !Number.isFinite(Date.parse(loginAt))) return null
|
||||||
|
if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt) || expiresAt <= now) return null
|
||||||
|
|
||||||
|
return { email, role, token, loginAt, expiresAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeBase64Url(value: string): Uint8Array | null {
|
||||||
|
if (!value || !BASE64URL_PATTERN.test(value)) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const padding = '='.repeat((4 - (value.length % 4)) % 4)
|
||||||
|
const binary = atob(value.replace(/-/g, '+').replace(/_/g, '/') + padding)
|
||||||
|
const bytes = new Uint8Array(binary.length)
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index)
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeAdminSessionPayload(encodedPayload: string): AdminSession | null {
|
||||||
|
const bytes = decodeBase64Url(encodedPayload)
|
||||||
|
if (!bytes || bytes.byteLength === 0 || bytes.byteLength > 16_384) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return parseAdminSession(JSON.parse(new TextDecoder().decode(bytes)))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADMIN_COOKIE_SECURE=false: TLS 종료 프록시 없이 LAN HTTP(예: http://NAS_IP:3001)로
|
||||||
|
// 직접 접속하는 배포에서만 사용. Secure 쿠키는 HTTP에서 브라우저가 버려 로그인이 유지되지 않는다.
|
||||||
|
export function adminCookieSecure(): boolean {
|
||||||
|
if (process.env.ADMIN_COOKIE_SECURE === 'false') return false
|
||||||
|
return process.env.NODE_ENV === 'production'
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
// apps/admin/src/lib/api-server.ts
|
import 'server-only'
|
||||||
// Helper library for connecting Next.js apps/admin to C# .NET API Backend & High-Fidelity D3RO Telemetry
|
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'
|
import { fetchAdminBackend } from './backend-session'
|
||||||
|
|
||||||
export interface SystemNodeHealth {
|
export interface SystemNodeHealth {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -16,44 +15,24 @@ export interface SystemNodeHealth {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PipelineStats {
|
export interface PipelineStats {
|
||||||
whisper: {
|
whisper: { engine: string; activeModel: string; avgLatencyMs: number; speedupFactor: string; partialStreamingFps: number; totalTranscriptionsToday: number; gpuVramUsage: string }
|
||||||
engine: string
|
ollama: { version: string; loadedModels: string[]; activeContextLimit: number; tokensPerSecond: number; vramAllocated: string; activeSessions: number }
|
||||||
activeModel: string
|
realtimeVoice: { backend: string; activeStreams: number; streamUptime: number; localFallbackRate: string; avgAudioRttMs: number }
|
||||||
avgLatencyMs: number
|
ragVector: { embeddingModel: string; indexedDocuments: number; totalVectorChunks: number; avgSearchLatencyMs: number; topHitRatePercent: number }
|
||||||
speedupFactor: string
|
meetingIntelligence: { diarizationEngine: string; speakerAccuracyPercent: number; activeMeetingSessions: number; templatesGeneratedToday: number; mindmapsExported: number }
|
||||||
partialStreamingFps: number
|
}
|
||||||
totalTranscriptionsToday: number
|
|
||||||
gpuVramUsage: string
|
export interface FeatureUsageBreakdown {
|
||||||
}
|
featureId: string
|
||||||
ollama: {
|
featureName: string
|
||||||
version: string
|
category: string
|
||||||
loadedModels: string[]
|
totalCalls?: number
|
||||||
activeContextLimit: number
|
callCount?: number
|
||||||
tokensPerSecond: number
|
percentage?: number
|
||||||
vramAllocated: string
|
tokensUsed: number
|
||||||
activeSessions: number
|
totalCost: number
|
||||||
}
|
estimatedCostUsd?: number
|
||||||
realtimeVoice: {
|
avgLatencyMs: number
|
||||||
backend: string
|
|
||||||
activeStreams: number
|
|
||||||
streamUptime: number
|
|
||||||
localFallbackRate: string
|
|
||||||
avgAudioRttMs: number
|
|
||||||
}
|
|
||||||
ragVector: {
|
|
||||||
embeddingModel: string
|
|
||||||
indexedDocuments: number
|
|
||||||
totalVectorChunks: number
|
|
||||||
avgSearchLatencyMs: number
|
|
||||||
topHitRatePercent: number
|
|
||||||
}
|
|
||||||
meetingIntelligence: {
|
|
||||||
diarizationEngine: string
|
|
||||||
speakerAccuracyPercent: number
|
|
||||||
activeMeetingSessions: number
|
|
||||||
templatesGeneratedToday: number
|
|
||||||
mindmapsExported: number
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerStats {
|
export interface ServerStats {
|
||||||
|
|
@ -63,44 +42,27 @@ export interface ServerStats {
|
||||||
totalCost: number
|
totalCost: number
|
||||||
serverUptimeSeconds: number
|
serverUptimeSeconds: number
|
||||||
errorCount: number
|
errorCount: number
|
||||||
arrUsd: number
|
arrUsd: number | null
|
||||||
mrrUsd: number
|
mrrUsd: number | null
|
||||||
tierDistribution: {
|
tierDistribution: { free: number; pro: number; pro_plus: number } | null
|
||||||
free: number
|
|
||||||
pro: number
|
|
||||||
pro_plus: number
|
|
||||||
}
|
|
||||||
nodes: SystemNodeHealth[]
|
nodes: SystemNodeHealth[]
|
||||||
pipelines: PipelineStats
|
pipelines: PipelineStats | null
|
||||||
featureBreakdown: FeatureUsageBreakdown[]
|
featureBreakdown: FeatureUsageBreakdown[]
|
||||||
recentErrors: Array<{
|
recentErrors: Array<{ id: number; errorType: string; message: string; endpoint: string | null; createdAt: string }>
|
||||||
id: number
|
|
||||||
errorType: string
|
|
||||||
message: string
|
|
||||||
endpoint: string | null
|
|
||||||
createdAt: string
|
|
||||||
}>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserItem {
|
export interface UserItem {
|
||||||
id: number
|
id: string
|
||||||
uid: string
|
uid: string
|
||||||
email: string
|
email: string
|
||||||
name: string
|
name: string
|
||||||
role: 'user' | 'manager' | 'admin' | 'super_admin'
|
role: 'user' | 'manager' | 'admin' | 'super_admin'
|
||||||
tier: 'free' | 'pro' | 'pro_plus'
|
tier: 'free' | 'pro' | 'pro_plus' | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
lastLoginAt: string | null
|
lastLoginAt: string | null
|
||||||
lastActiveDevice: string
|
lastActiveDevice: string | null
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
dailyUsage: {
|
dailyUsage: { dictations: number; dictationsMax: number | null; llmCalls: number; llmCallsMax: number | null; ragQueries: number; ragQueriesMax: number | null } | null
|
||||||
dictations: number
|
|
||||||
dictationsMax: number
|
|
||||||
llmCalls: number
|
|
||||||
llmCallsMax: number
|
|
||||||
ragQueries: number
|
|
||||||
ragQueriesMax: number
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelEndpoint {
|
export interface ModelEndpoint {
|
||||||
|
|
@ -112,21 +74,13 @@ export interface ModelEndpoint {
|
||||||
apiKey: string
|
apiKey: string
|
||||||
costPer1kPromptTokens: number
|
costPer1kPromptTokens: number
|
||||||
costPer1kCompletionTokens: number
|
costPer1kCompletionTokens: number
|
||||||
latencyMs: number
|
latencyMs?: number
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
isDefault: boolean
|
isDefault?: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type STTProviderCategory =
|
export type STTProviderCategory = 'groq' | 'openai' | 'deepgram' | 'google' | 'assemblyai' | 'azure' | 'custom' | 'local-sidecar'
|
||||||
| 'groq'
|
|
||||||
| 'openai'
|
|
||||||
| 'deepgram'
|
|
||||||
| 'google'
|
|
||||||
| 'assemblyai'
|
|
||||||
| 'azure'
|
|
||||||
| 'custom'
|
|
||||||
| 'local-sidecar'
|
|
||||||
|
|
||||||
export interface SttProviderEndpoint {
|
export interface SttProviderEndpoint {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -166,25 +120,10 @@ export interface CreateSttEndpointDto {
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
fallbackPriority?: number
|
fallbackPriority?: number
|
||||||
extraHeadersJson?: string
|
extraHeadersJson?: string
|
||||||
|
memo: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateSttEndpointDto {
|
export type UpdateSttEndpointDto = CreateSttEndpointDto
|
||||||
name: string
|
|
||||||
providerType: STTProviderCategory
|
|
||||||
endpointUrl: string
|
|
||||||
apiKey?: string
|
|
||||||
modelId: string
|
|
||||||
method: string
|
|
||||||
language?: string
|
|
||||||
prompt?: string
|
|
||||||
temperature?: number
|
|
||||||
costPerMinute: number
|
|
||||||
costPerSecond?: number
|
|
||||||
isDefault?: boolean
|
|
||||||
isActive?: boolean
|
|
||||||
fallbackPriority?: number
|
|
||||||
extraHeadersJson?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SttTestResult {
|
export interface SttTestResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
|
|
@ -200,34 +139,8 @@ export interface SttUsageReport {
|
||||||
totalAudioMinutes: number
|
totalAudioMinutes: number
|
||||||
totalCost: number
|
totalCost: number
|
||||||
avgLatencyMs: number
|
avgLatencyMs: number
|
||||||
providerSummaries: Array<{
|
providerSummaries: Array<{ provider: string; modelId: string; totalRequests: number; totalAudioMinutes: number; totalCost: number; avgLatencyMs: number }>
|
||||||
provider: string
|
userSummaries: Array<{ userId: number; email: string; totalRequests: number; totalAudioMinutes: number; totalCost: number }>
|
||||||
modelId: string
|
|
||||||
totalRequests: number
|
|
||||||
totalAudioMinutes: number
|
|
||||||
totalCost: number
|
|
||||||
avgLatencyMs: number
|
|
||||||
}>
|
|
||||||
userSummaries: Array<{
|
|
||||||
userId: number
|
|
||||||
email: string
|
|
||||||
totalRequests: number
|
|
||||||
totalAudioMinutes: number
|
|
||||||
totalCost: number
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FeatureUsageBreakdown {
|
|
||||||
featureId: string
|
|
||||||
featureName: string
|
|
||||||
category: string
|
|
||||||
totalCalls?: number
|
|
||||||
callCount?: number
|
|
||||||
percentage?: number
|
|
||||||
tokensUsed: number
|
|
||||||
totalCost: number
|
|
||||||
estimatedCostUsd?: number
|
|
||||||
avgLatencyMs: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsageReport {
|
export interface UsageReport {
|
||||||
|
|
@ -235,712 +148,92 @@ export interface UsageReport {
|
||||||
totalPromptTokens: number
|
totalPromptTokens: number
|
||||||
totalCompletionTokens: number
|
totalCompletionTokens: number
|
||||||
totalCost: number
|
totalCost: number
|
||||||
timeline: Array<{
|
timeline: Array<{ date: string; dictations: number; meetingSummaries: number; aiChat: number; ragSearch: number; voiceRealtime: number; totalCost: number }>
|
||||||
date: string
|
|
||||||
dictations: number
|
|
||||||
meetingSummaries: number
|
|
||||||
aiChat: number
|
|
||||||
ragSearch: number
|
|
||||||
voiceRealtime: number
|
|
||||||
totalCost: number
|
|
||||||
}>
|
|
||||||
features: FeatureUsageBreakdown[]
|
features: FeatureUsageBreakdown[]
|
||||||
userSummaries: Array<{
|
userSummaries: Array<{ userId: number; email: string; name?: string; tier?: string; totalRequests: number; totalTokens: number; totalCost: number }>
|
||||||
userId: number
|
modelSummaries: Array<{ modelId: string; modelName: string; provider?: string; totalRequests: number; totalTokens: number; totalCost: number }>
|
||||||
email: string
|
|
||||||
name: string
|
|
||||||
tier: string
|
|
||||||
totalRequests: number
|
|
||||||
totalTokens: number
|
|
||||||
totalCost: number
|
|
||||||
}>
|
|
||||||
modelSummaries: Array<{
|
|
||||||
modelId: string
|
|
||||||
modelName: string
|
|
||||||
provider: string
|
|
||||||
totalRequests: number
|
|
||||||
totalTokens: number
|
|
||||||
totalCost: number
|
|
||||||
}>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Realistic Mock Fallbacks (D3RO Voice v0.2.1 / Phase 15.5 SSOT) ─────────
|
async function readJson<T>(path: string): Promise<T> {
|
||||||
|
const response = await fetchAdminBackend(path)
|
||||||
const MOCK_NODES: SystemNodeHealth[] = [
|
if (!response.ok) throw new Error(`Admin backend request failed (${response.status})`)
|
||||||
{
|
try {
|
||||||
id: 'whisper-sidecar',
|
return (await response.json()) as T
|
||||||
name: 'Faster-Whisper STT Engine',
|
} catch {
|
||||||
category: 'stt',
|
throw new Error('Admin backend returned invalid JSON')
|
||||||
status: 'operational',
|
}
|
||||||
latencyMs: 142,
|
|
||||||
uptimePercent: 99.92,
|
|
||||||
versionOrModel: 'large-v3-turbo (PyInstaller)',
|
|
||||||
vramOrMemory: '3.2 GB / 8.0 GB',
|
|
||||||
details: 'Dual-condition parallel buffer flush • 6x speedup active',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ollama-local',
|
|
||||||
name: 'Bundled Ollama Runtime',
|
|
||||||
category: 'llm',
|
|
||||||
status: 'operational',
|
|
||||||
latencyMs: 48,
|
|
||||||
uptimePercent: 99.85,
|
|
||||||
versionOrModel: 'Ollama v0.32.1 (gemma4:e4b)',
|
|
||||||
vramOrMemory: '4.6 GB / 8.0 GB',
|
|
||||||
details: 'Pruned slim 119MB runtime • NDJSON streaming active',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'realtime-voice',
|
|
||||||
name: 'GPT-Realtime 2.1 Live Engine',
|
|
||||||
category: 'voice_realtime',
|
|
||||||
status: 'operational',
|
|
||||||
latencyMs: 185,
|
|
||||||
uptimePercent: 99.78,
|
|
||||||
versionOrModel: 'gpt-realtime-2.1 (Premium WebSocket)',
|
|
||||||
vramOrMemory: 'Cloud Managed',
|
|
||||||
details: 'Dual audio loopback • Local pipeline auto-fallback ready',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rag-sqlite',
|
|
||||||
name: 'Vector RAG & Embeddings',
|
|
||||||
category: 'rag_vector',
|
|
||||||
status: 'operational',
|
|
||||||
latencyMs: 18,
|
|
||||||
uptimePercent: 99.98,
|
|
||||||
versionOrModel: 'nomic-embed-text-v1.5',
|
|
||||||
vramOrMemory: '512 MB SQLite Vector',
|
|
||||||
details: 'Cosine similarity • 4,820 documents indexed',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'diarization-pyannote',
|
|
||||||
name: 'Speaker Diarization Engine',
|
|
||||||
category: 'diarization',
|
|
||||||
status: 'operational',
|
|
||||||
latencyMs: 210,
|
|
||||||
uptimePercent: 99.64,
|
|
||||||
versionOrModel: 'Pyannote 3.1 + LLM Attribution',
|
|
||||||
vramOrMemory: '1.4 GB VRAM',
|
|
||||||
details: 'Multi-speaker voiceprint clustering (Phase 15.5)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'csharp-gateway',
|
|
||||||
name: 'C# .NET Core Gateway API',
|
|
||||||
category: 'backend_api',
|
|
||||||
status: 'operational',
|
|
||||||
latencyMs: 32,
|
|
||||||
uptimePercent: 99.99,
|
|
||||||
versionOrModel: '.NET 9.0 WebAPI',
|
|
||||||
vramOrMemory: '320 MB RAM',
|
|
||||||
details: 'Telemetry & token cost accounting active',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const MOCK_PIPELINES: PipelineStats = {
|
|
||||||
whisper: {
|
|
||||||
engine: 'faster-whisper (Python 3.11 sidecar)',
|
|
||||||
activeModel: 'large-v3-turbo (default)',
|
|
||||||
avgLatencyMs: 142,
|
|
||||||
speedupFactor: '6.2x vs base',
|
|
||||||
partialStreamingFps: 10,
|
|
||||||
totalTranscriptionsToday: 4890,
|
|
||||||
gpuVramUsage: '3.2 GB',
|
|
||||||
},
|
|
||||||
ollama: {
|
|
||||||
version: 'v0.32.1 (Bundled)',
|
|
||||||
loadedModels: ['gemma4:e4b', 'qwen2.5:7b', 'llama3:8b'],
|
|
||||||
activeContextLimit: 8192,
|
|
||||||
tokensPerSecond: 44.5,
|
|
||||||
vramAllocated: '4.6 GB',
|
|
||||||
activeSessions: 8,
|
|
||||||
},
|
|
||||||
realtimeVoice: {
|
|
||||||
backend: 'OpenAI GPT-Realtime 2.1 Audio WS',
|
|
||||||
activeStreams: 18,
|
|
||||||
streamUptime: 99.8,
|
|
||||||
localFallbackRate: '1.8%',
|
|
||||||
avgAudioRttMs: 185,
|
|
||||||
},
|
|
||||||
ragVector: {
|
|
||||||
embeddingModel: 'nomic-embed-text (SQLite Vector DB)',
|
|
||||||
indexedDocuments: 4820,
|
|
||||||
totalVectorChunks: 42900,
|
|
||||||
avgSearchLatencyMs: 18.4,
|
|
||||||
topHitRatePercent: 94.6,
|
|
||||||
},
|
|
||||||
meetingIntelligence: {
|
|
||||||
diarizationEngine: 'pyannote 3.1 + LLM speaker fallback',
|
|
||||||
speakerAccuracyPercent: 96.4,
|
|
||||||
activeMeetingSessions: 14,
|
|
||||||
templatesGeneratedToday: 86,
|
|
||||||
mindmapsExported: 42,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOCK_USERS: UserItem[] = [
|
function normalizeRole(value: unknown): UserItem['role'] {
|
||||||
{
|
const normalized = typeof value === 'string' ? value.replace(/[_-]/g, '').toLowerCase() : ''
|
||||||
id: 1,
|
if (normalized === 'superadmin') return 'super_admin'
|
||||||
uid: 'usr_d3ro_001',
|
if (normalized === 'admin') return 'admin'
|
||||||
email: 'admin@d3ro.voice',
|
if (normalized === 'manager') return 'manager'
|
||||||
name: 'D3RO System Architect',
|
return 'user'
|
||||||
role: 'super_admin',
|
|
||||||
tier: 'pro_plus',
|
|
||||||
createdAt: '2026-01-15T09:00:00Z',
|
|
||||||
lastLoginAt: '2026-08-19T02:45:00Z',
|
|
||||||
lastActiveDevice: 'Windows 11 x64 (Build 26100)',
|
|
||||||
isActive: true,
|
|
||||||
dailyUsage: { dictations: 42, dictationsMax: 9999, llmCalls: 128, llmCallsMax: 9999, ragQueries: 35, ragQueriesMax: 9999 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
uid: 'usr_d3ro_002',
|
|
||||||
email: 'sarah.kim@techcorp.io',
|
|
||||||
name: 'Sarah Kim',
|
|
||||||
role: 'admin',
|
|
||||||
tier: 'pro_plus',
|
|
||||||
createdAt: '2026-03-10T14:20:00Z',
|
|
||||||
lastLoginAt: '2026-08-19T01:30:00Z',
|
|
||||||
lastActiveDevice: 'macOS 15.4 arm64 (Apple M3 Max)',
|
|
||||||
isActive: true,
|
|
||||||
dailyUsage: { dictations: 184, dictationsMax: 9999, llmCalls: 86, llmCallsMax: 9999, ragQueries: 18, ragQueriesMax: 9999 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
uid: 'usr_d3ro_003',
|
|
||||||
email: 'minho.park@innovate.kr',
|
|
||||||
name: 'Minho Park',
|
|
||||||
role: 'user',
|
|
||||||
tier: 'pro_plus',
|
|
||||||
createdAt: '2026-04-02T11:15:00Z',
|
|
||||||
lastLoginAt: '2026-08-18T22:10:00Z',
|
|
||||||
lastActiveDevice: 'Windows 11 x64',
|
|
||||||
isActive: true,
|
|
||||||
dailyUsage: { dictations: 92, dictationsMax: 9999, llmCalls: 45, llmCallsMax: 9999, ragQueries: 12, ragQueriesMax: 9999 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
uid: 'usr_d3ro_004',
|
|
||||||
email: 'alex.chen@globalai.dev',
|
|
||||||
name: 'Alex Chen',
|
|
||||||
role: 'user',
|
|
||||||
tier: 'pro',
|
|
||||||
createdAt: '2026-05-18T16:40:00Z',
|
|
||||||
lastLoginAt: '2026-08-18T19:55:00Z',
|
|
||||||
lastActiveDevice: 'macOS 15.3 arm64 (Apple M2)',
|
|
||||||
isActive: true,
|
|
||||||
dailyUsage: { dictations: 64, dictationsMax: 9999, llmCalls: 142, llmCallsMax: 200, ragQueries: 5, ragQueriesMax: 10 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
uid: 'usr_d3ro_005',
|
|
||||||
email: 'jisoo.lee@creator.studio',
|
|
||||||
name: 'Jisoo Lee',
|
|
||||||
role: 'user',
|
|
||||||
tier: 'pro',
|
|
||||||
createdAt: '2026-06-01T08:12:00Z',
|
|
||||||
lastLoginAt: '2026-08-19T00:15:00Z',
|
|
||||||
lastActiveDevice: 'Windows 11 x64',
|
|
||||||
isActive: true,
|
|
||||||
dailyUsage: { dictations: 48, dictationsMax: 9999, llmCalls: 78, llmCallsMax: 200, ragQueries: 4, ragQueriesMax: 10 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
uid: 'usr_d3ro_006',
|
|
||||||
email: 'david.wilson@voicepod.com',
|
|
||||||
name: 'David Wilson',
|
|
||||||
role: 'manager',
|
|
||||||
tier: 'pro_plus',
|
|
||||||
createdAt: '2026-06-20T10:00:00Z',
|
|
||||||
lastLoginAt: '2026-08-18T15:22:00Z',
|
|
||||||
lastActiveDevice: 'macOS 15.4 arm64',
|
|
||||||
isActive: true,
|
|
||||||
dailyUsage: { dictations: 120, dictationsMax: 9999, llmCalls: 95, llmCallsMax: 9999, ragQueries: 28, ragQueriesMax: 9999 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
uid: 'usr_d3ro_007',
|
|
||||||
email: 'hyunjin.choi@startup.io',
|
|
||||||
name: 'Hyunjin Choi',
|
|
||||||
role: 'user',
|
|
||||||
tier: 'free',
|
|
||||||
createdAt: '2026-07-11T13:45:00Z',
|
|
||||||
lastLoginAt: '2026-08-19T02:10:00Z',
|
|
||||||
lastActiveDevice: 'Windows 10 x64',
|
|
||||||
isActive: true,
|
|
||||||
dailyUsage: { dictations: 18, dictationsMax: 20, llmCalls: 9, llmCallsMax: 10, ragQueries: 0, ragQueriesMax: 0 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 8,
|
|
||||||
uid: 'usr_d3ro_008',
|
|
||||||
email: 'elena.rostova@designlab.eu',
|
|
||||||
name: 'Elena Rostova',
|
|
||||||
role: 'user',
|
|
||||||
tier: 'free',
|
|
||||||
createdAt: '2026-08-01T17:30:00Z',
|
|
||||||
lastLoginAt: '2026-08-17T12:00:00Z',
|
|
||||||
lastActiveDevice: 'macOS 15.2 arm64',
|
|
||||||
isActive: true,
|
|
||||||
dailyUsage: { dictations: 8, dictationsMax: 20, llmCalls: 3, llmCallsMax: 10, ragQueries: 0, ragQueriesMax: 0 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const MOCK_ENDPOINTS: ModelEndpoint[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
modelId: 'whisper-large-v3-turbo',
|
|
||||||
modelName: 'Faster-Whisper Large-v3 Turbo (Local)',
|
|
||||||
provider: 'Local Sidecar',
|
|
||||||
endpointUrl: 'http://localhost:8971/stt/transcribe',
|
|
||||||
apiKey: 'internal-sidecar-token',
|
|
||||||
costPer1kPromptTokens: 0.000000,
|
|
||||||
costPer1kCompletionTokens: 0.000000,
|
|
||||||
latencyMs: 142,
|
|
||||||
isActive: true,
|
|
||||||
isDefault: true,
|
|
||||||
createdAt: '2026-01-15T09:00:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
modelId: 'ollama-gemma4-e4b',
|
|
||||||
modelName: 'Ollama Gemma-4 E4B (Bundled Local)',
|
|
||||||
provider: 'Ollama Local',
|
|
||||||
endpointUrl: 'http://localhost:11434/api/generate',
|
|
||||||
apiKey: '',
|
|
||||||
costPer1kPromptTokens: 0.000000,
|
|
||||||
costPer1kCompletionTokens: 0.000000,
|
|
||||||
latencyMs: 48,
|
|
||||||
isActive: true,
|
|
||||||
isDefault: true,
|
|
||||||
createdAt: '2026-02-01T10:00:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
modelId: 'gpt-realtime-2.1',
|
|
||||||
modelName: 'OpenAI GPT-Realtime 2.1 (Live Voice)',
|
|
||||||
provider: 'OpenAI',
|
|
||||||
endpointUrl: 'wss://api.openai.com/v1/realtime',
|
|
||||||
apiKey: 'sk-proj-rt-••••••••',
|
|
||||||
costPer1kPromptTokens: 0.005000,
|
|
||||||
costPer1kCompletionTokens: 0.020000,
|
|
||||||
latencyMs: 185,
|
|
||||||
isActive: true,
|
|
||||||
isDefault: false,
|
|
||||||
createdAt: '2026-05-10T12:00:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
modelId: 'gpt-4o-mini',
|
|
||||||
modelName: 'GPT-4o Mini (Cloud Synthesis & Meeting)',
|
|
||||||
provider: 'OpenAI',
|
|
||||||
endpointUrl: 'https://api.openai.com/v1/chat/completions',
|
|
||||||
apiKey: 'sk-proj-••••••••',
|
|
||||||
costPer1kPromptTokens: 0.000150,
|
|
||||||
costPer1kCompletionTokens: 0.000600,
|
|
||||||
latencyMs: 240,
|
|
||||||
isActive: true,
|
|
||||||
isDefault: false,
|
|
||||||
createdAt: '2026-04-12T08:00:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
modelId: 'claude-3-5-sonnet',
|
|
||||||
modelName: 'Claude 3.5 Sonnet (Complex Action Planning)',
|
|
||||||
provider: 'Anthropic',
|
|
||||||
endpointUrl: 'https://api.anthropic.com/v1/messages',
|
|
||||||
apiKey: 'sk-ant-••••••••',
|
|
||||||
costPer1kPromptTokens: 0.003000,
|
|
||||||
costPer1kCompletionTokens: 0.015000,
|
|
||||||
latencyMs: 380,
|
|
||||||
isActive: true,
|
|
||||||
isDefault: false,
|
|
||||||
createdAt: '2026-06-01T14:00:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
modelId: 'nomic-embed-text',
|
|
||||||
modelName: 'Nomic Embed Text v1.5 (RAG Embeddings)',
|
|
||||||
provider: 'Ollama Local',
|
|
||||||
endpointUrl: 'http://localhost:11434/api/embeddings',
|
|
||||||
apiKey: '',
|
|
||||||
costPer1kPromptTokens: 0.000000,
|
|
||||||
costPer1kCompletionTokens: 0.000000,
|
|
||||||
latencyMs: 18,
|
|
||||||
isActive: true,
|
|
||||||
isDefault: true,
|
|
||||||
createdAt: '2026-03-20T11:00:00Z',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const MOCK_USAGE_REPORT: UsageReport = {
|
|
||||||
totalRequests: 142890,
|
|
||||||
totalPromptTokens: 28450120,
|
|
||||||
totalCompletionTokens: 14210980,
|
|
||||||
totalCost: 24.8912,
|
|
||||||
timeline: [
|
|
||||||
{ date: '2026-08-13', dictations: 1420, meetingSummaries: 38, aiChat: 310, ragSearch: 180, voiceRealtime: 42, totalCost: 2.841 },
|
|
||||||
{ date: '2026-08-14', dictations: 1680, meetingSummaries: 45, aiChat: 345, ragSearch: 210, voiceRealtime: 58, totalCost: 3.290 },
|
|
||||||
{ date: '2026-08-15', dictations: 1890, meetingSummaries: 52, aiChat: 410, ragSearch: 260, voiceRealtime: 64, totalCost: 3.840 },
|
|
||||||
{ date: '2026-08-16', dictations: 1250, meetingSummaries: 28, aiChat: 280, ragSearch: 140, voiceRealtime: 35, totalCost: 2.120 },
|
|
||||||
{ date: '2026-08-17', dictations: 1120, meetingSummaries: 22, aiChat: 240, ragSearch: 110, voiceRealtime: 30, totalCost: 1.940 },
|
|
||||||
{ date: '2026-08-18', dictations: 2140, meetingSummaries: 74, aiChat: 520, ragSearch: 380, voiceRealtime: 88, totalCost: 5.120 },
|
|
||||||
{ date: '2026-08-19', dictations: 2480, meetingSummaries: 86, aiChat: 610, ragSearch: 420, voiceRealtime: 104, totalCost: 5.740 },
|
|
||||||
],
|
|
||||||
features: [
|
|
||||||
{ featureId: 'dictation', featureName: 'Realtime Dictation (Whisper Turbo)', category: 'STT', callCount: 88420, tokensUsed: 12400000, totalCost: 0.00, avgLatencyMs: 142 },
|
|
||||||
{ featureId: 'meeting_mode', featureName: 'Meeting Mode + Multi-Doc Generator', category: 'Intelligence', callCount: 12840, tokensUsed: 8920000, totalCost: 6.42, avgLatencyMs: 680 },
|
|
||||||
{ featureId: 'speaker_diarization', featureName: 'Speaker Diarization (pyannote + LLM)', category: 'Audio', callCount: 14200, tokensUsed: 4200000, totalCost: 2.10, avgLatencyMs: 210 },
|
|
||||||
{ featureId: 'voice_realtime', featureName: 'Live Voice Conversation (Realtime)', category: 'Voice Mode', callCount: 6890, tokensUsed: 5410000, totalCost: 11.24, avgLatencyMs: 185 },
|
|
||||||
{ featureId: 'rag_search', featureName: 'Vector RAG Document Semantic Search', category: 'Knowledge', callCount: 12540, tokensUsed: 1240000, totalCost: 0.89, avgLatencyMs: 18 },
|
|
||||||
{ featureId: 'auto_polish', featureName: 'Auto Polish & Grammar Refinement', category: 'LLM', callCount: 8000, tokensUsed: 1091100, totalCost: 4.24, avgLatencyMs: 48 },
|
|
||||||
],
|
|
||||||
userSummaries: [
|
|
||||||
{ userId: 2, email: 'sarah.kim@techcorp.io', name: 'Sarah Kim', tier: 'pro_plus', totalRequests: 18420, totalTokens: 6420000, totalCost: 5.842 },
|
|
||||||
{ userId: 1, email: 'admin@d3ro.voice', name: 'D3RO Admin', tier: 'pro_plus', totalRequests: 14200, totalTokens: 4890000, totalCost: 4.120 },
|
|
||||||
{ userId: 6, email: 'david.wilson@voicepod.com', name: 'David Wilson', tier: 'pro_plus', totalRequests: 12400, totalTokens: 3820000, totalCost: 3.450 },
|
|
||||||
{ userId: 3, email: 'minho.park@innovate.kr', name: 'Minho Park', tier: 'pro_plus', totalRequests: 9840, totalTokens: 2940000, totalCost: 2.640 },
|
|
||||||
{ userId: 4, email: 'alex.chen@globalai.dev', name: 'Alex Chen', tier: 'pro', totalRequests: 8200, totalTokens: 2410000, totalCost: 1.820 },
|
|
||||||
{ userId: 5, email: 'jisoo.lee@creator.studio', name: 'Jisoo Lee', tier: 'pro', totalRequests: 6400, totalTokens: 1890000, totalCost: 1.420 },
|
|
||||||
],
|
|
||||||
modelSummaries: [
|
|
||||||
{ modelId: 'whisper-large-v3-turbo', modelName: 'Faster-Whisper Large-v3 Turbo', provider: 'Local Sidecar', totalRequests: 88420, totalTokens: 12400000, totalCost: 0.00 },
|
|
||||||
{ modelId: 'ollama-gemma4-e4b', modelName: 'Ollama Gemma-4 E4B', provider: 'Ollama Local', totalRequests: 32400, totalTokens: 14820000, totalCost: 0.00 },
|
|
||||||
{ modelId: 'gpt-realtime-2.1', modelName: 'OpenAI GPT-Realtime 2.1', provider: 'OpenAI', totalRequests: 6890, totalTokens: 5410000, totalCost: 11.24 },
|
|
||||||
{ modelId: 'gpt-4o-mini', modelName: 'GPT-4o Mini', provider: 'OpenAI', totalRequests: 12840, totalTokens: 8920000, totalCost: 6.42 },
|
|
||||||
{ modelId: 'claude-3-5-sonnet', modelName: 'Claude 3.5 Sonnet', provider: 'Anthropic', totalRequests: 2340, totalTokens: 1111100, totalCost: 7.23 },
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOCK_FEATURE_BREAKDOWN: FeatureUsageBreakdown[] = [
|
|
||||||
{ featureId: 'dictation', featureName: 'Realtime Dictation (Whisper Turbo)', category: 'STT', totalCalls: 88420, percentage: 61.8, tokensUsed: 12400000, totalCost: 0.00, estimatedCostUsd: 0.00, avgLatencyMs: 142 },
|
|
||||||
{ featureId: 'meeting_mode', featureName: 'Meeting Mode + Multi-Doc Generator', category: 'Intelligence', totalCalls: 12840, percentage: 9.0, tokensUsed: 8920000, totalCost: 6.42, estimatedCostUsd: 6.42, avgLatencyMs: 680 },
|
|
||||||
{ featureId: 'speaker_diarization', featureName: 'Speaker Diarization (Pyannote + LLM)', category: 'Audio', totalCalls: 14200, percentage: 9.9, tokensUsed: 4200000, totalCost: 2.10, estimatedCostUsd: 2.10, avgLatencyMs: 210 },
|
|
||||||
{ featureId: 'voice_realtime', featureName: 'Live Voice Conversation (Realtime)', category: 'Voice Mode', totalCalls: 6890, percentage: 4.8, tokensUsed: 5410000, totalCost: 11.24, estimatedCostUsd: 11.24, avgLatencyMs: 185 },
|
|
||||||
{ featureId: 'rag_search', featureName: 'Vector RAG Document Semantic Search', category: 'Knowledge', totalCalls: 12540, percentage: 8.8, tokensUsed: 1240000, totalCost: 0.89, estimatedCostUsd: 0.89, avgLatencyMs: 18 },
|
|
||||||
{ featureId: 'auto_polish', featureName: 'Auto Polish & Grammar Refinement', category: 'LLM', totalCalls: 8000, percentage: 5.7, tokensUsed: 1091100, totalCost: 4.24, estimatedCostUsd: 4.24, avgLatencyMs: 48 },
|
|
||||||
]
|
|
||||||
|
|
||||||
// ── API Fetch Functions ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function fetchServerStats(): Promise<ServerStats> {
|
export async function fetchServerStats(): Promise<ServerStats> {
|
||||||
try {
|
const data = await readJson<Partial<ServerStats>>('/stats')
|
||||||
const res = await fetch(`${API_BASE}/api/admin/stats`, { cache: 'no-store' })
|
if (typeof data.totalUsers !== 'number' || typeof data.activeUsersToday !== 'number' || typeof data.totalRequests !== 'number' || typeof data.totalCost !== 'number' || typeof data.serverUptimeSeconds !== 'number' || typeof data.errorCount !== 'number') {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
throw new Error('Admin stats response is incomplete')
|
||||||
const data = await res.json()
|
}
|
||||||
return {
|
return {
|
||||||
totalUsers: data.totalUsers ?? 4580,
|
totalUsers: data.totalUsers,
|
||||||
activeUsersToday: data.activeUsersToday ?? 1240,
|
activeUsersToday: data.activeUsersToday,
|
||||||
totalRequests: data.totalRequests ?? 142890,
|
totalRequests: data.totalRequests,
|
||||||
totalCost: data.totalCost ?? 24.8912,
|
totalCost: data.totalCost,
|
||||||
serverUptimeSeconds: data.serverUptimeSeconds ?? 864200,
|
serverUptimeSeconds: data.serverUptimeSeconds,
|
||||||
errorCount: data.errorCount ?? 0,
|
errorCount: data.errorCount,
|
||||||
arrUsd: 231480,
|
arrUsd: typeof data.arrUsd === 'number' ? data.arrUsd : null,
|
||||||
mrrUsd: 19290,
|
mrrUsd: typeof data.mrrUsd === 'number' ? data.mrrUsd : null,
|
||||||
tierDistribution: { free: 3420, pro: 842, pro_plus: 318 },
|
tierDistribution: data.tierDistribution ?? null,
|
||||||
nodes: MOCK_NODES,
|
nodes: Array.isArray(data.nodes) ? data.nodes : [],
|
||||||
pipelines: MOCK_PIPELINES,
|
pipelines: data.pipelines ?? null,
|
||||||
featureBreakdown: MOCK_FEATURE_BREAKDOWN,
|
featureBreakdown: Array.isArray(data.featureBreakdown) ? data.featureBreakdown : [],
|
||||||
recentErrors: data.recentErrors ?? [],
|
recentErrors: Array.isArray(data.recentErrors) ? data.recentErrors : []
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
totalUsers: 4580,
|
|
||||||
activeUsersToday: 1240,
|
|
||||||
totalRequests: 142890,
|
|
||||||
totalCost: 24.8912,
|
|
||||||
serverUptimeSeconds: 864200,
|
|
||||||
errorCount: 0,
|
|
||||||
arrUsd: 231480,
|
|
||||||
mrrUsd: 19290,
|
|
||||||
tierDistribution: { free: 3420, pro: 842, pro_plus: 318 },
|
|
||||||
nodes: MOCK_NODES,
|
|
||||||
pipelines: MOCK_PIPELINES,
|
|
||||||
featureBreakdown: MOCK_FEATURE_BREAKDOWN,
|
|
||||||
recentErrors: [],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchUsers(): Promise<UserItem[]> {
|
export async function fetchUsers(): Promise<UserItem[]> {
|
||||||
try {
|
const data = await readJson<Array<Record<string, unknown>>>('/users')
|
||||||
const res = await fetch(`${API_BASE}/api/admin/users`, { cache: 'no-store' })
|
if (!Array.isArray(data)) throw new Error('Admin users response is invalid')
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
return data.map((item) => {
|
||||||
const data = await res.json()
|
if (typeof item.id !== 'number' || typeof item.email !== 'string' || typeof item.createdAt !== 'string') {
|
||||||
return Array.isArray(data) && data.length > 0 ? data : MOCK_USERS
|
throw new Error('Admin users response contains an invalid row')
|
||||||
} catch {
|
}
|
||||||
return MOCK_USERS
|
return {
|
||||||
}
|
id: String(item.id),
|
||||||
|
uid: String(item.id),
|
||||||
|
email: item.email,
|
||||||
|
name: item.email,
|
||||||
|
role: normalizeRole(item.role),
|
||||||
|
tier: null,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
lastLoginAt: typeof item.lastLoginAt === 'string' ? item.lastLoginAt : null,
|
||||||
|
lastActiveDevice: null,
|
||||||
|
isActive: item.isActive === true,
|
||||||
|
dailyUsage: null
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchModelEndpoints(): Promise<ModelEndpoint[]> {
|
export async function fetchModelEndpoints(): Promise<ModelEndpoint[]> {
|
||||||
try {
|
const data = await readJson<ModelEndpoint[]>('/endpoints')
|
||||||
const res = await fetch(`${API_BASE}/api/admin/endpoints`, { cache: 'no-store' })
|
if (!Array.isArray(data)) throw new Error('Model endpoints response is invalid')
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
return data
|
||||||
const data = await res.json()
|
|
||||||
return Array.isArray(data) && data.length > 0 ? data : MOCK_ENDPOINTS
|
|
||||||
} catch {
|
|
||||||
return MOCK_ENDPOINTS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createModelEndpoint(dto: {
|
|
||||||
modelId: string
|
|
||||||
modelName: string
|
|
||||||
provider: string
|
|
||||||
endpointUrl: string
|
|
||||||
apiKey: string
|
|
||||||
costPer1kPromptTokens: number
|
|
||||||
costPer1kCompletionTokens: number
|
|
||||||
}): Promise<ModelEndpoint> {
|
|
||||||
const res = await fetch(`${API_BASE}/api/admin/endpoints`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(dto),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`Failed to create model endpoint: ${res.statusText}`)
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteModelEndpoint(id: number): Promise<boolean> {
|
|
||||||
const res = await fetch(`${API_BASE}/api/admin/endpoints/${id}`, { method: 'DELETE' })
|
|
||||||
return res.ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── STT Provider API Fetch Functions ───────────────────────────────────────
|
|
||||||
|
|
||||||
export const MOCK_STT_ENDPOINTS: SttProviderEndpoint[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'Groq Whisper LPU Turbo (Ultra Fast)',
|
|
||||||
providerType: 'groq',
|
|
||||||
endpointUrl: 'https://api.groq.com/openai/v1/audio/transcriptions',
|
|
||||||
apiKey: '••••••••',
|
|
||||||
modelId: 'whisper-large-v3-turbo',
|
|
||||||
method: 'multipart',
|
|
||||||
language: 'ko',
|
|
||||||
prompt: null,
|
|
||||||
temperature: 0.0,
|
|
||||||
costPerMinute: 0.0005,
|
|
||||||
costPerSecond: 0.000008,
|
|
||||||
isDefault: true,
|
|
||||||
isActive: true,
|
|
||||||
fallbackPriority: 1,
|
|
||||||
extraHeadersJson: null,
|
|
||||||
latencyMs: 140,
|
|
||||||
createdAt: '2026-01-15T09:00:00Z',
|
|
||||||
updatedAt: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'OpenAI Whisper Official',
|
|
||||||
providerType: 'openai',
|
|
||||||
endpointUrl: 'https://api.openai.com/v1/audio/transcriptions',
|
|
||||||
apiKey: '••••••••',
|
|
||||||
modelId: 'whisper-1',
|
|
||||||
method: 'multipart',
|
|
||||||
language: 'ko',
|
|
||||||
prompt: null,
|
|
||||||
temperature: 0.0,
|
|
||||||
costPerMinute: 0.006,
|
|
||||||
costPerSecond: 0.0001,
|
|
||||||
isDefault: false,
|
|
||||||
isActive: true,
|
|
||||||
fallbackPriority: 2,
|
|
||||||
extraHeadersJson: null,
|
|
||||||
latencyMs: 380,
|
|
||||||
createdAt: '2026-02-01T10:00:00Z',
|
|
||||||
updatedAt: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'Deepgram Nova-3 Industry Standard',
|
|
||||||
providerType: 'deepgram',
|
|
||||||
endpointUrl: 'https://api.deepgram.com/v1/listen',
|
|
||||||
apiKey: '••••••••',
|
|
||||||
modelId: 'nova-3',
|
|
||||||
method: 'binary-stream',
|
|
||||||
language: 'ko',
|
|
||||||
prompt: null,
|
|
||||||
temperature: 0.0,
|
|
||||||
costPerMinute: 0.0043,
|
|
||||||
costPerSecond: 0.000072,
|
|
||||||
isDefault: false,
|
|
||||||
isActive: true,
|
|
||||||
fallbackPriority: 3,
|
|
||||||
extraHeadersJson: null,
|
|
||||||
latencyMs: 195,
|
|
||||||
createdAt: '2026-03-10T12:00:00Z',
|
|
||||||
updatedAt: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: 'Google Gemini 2.0 Flash / Cloud STT',
|
|
||||||
providerType: 'google',
|
|
||||||
endpointUrl: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent',
|
|
||||||
apiKey: '••••••••',
|
|
||||||
modelId: 'gemini-2.0-flash',
|
|
||||||
method: 'json-base64',
|
|
||||||
language: 'ko',
|
|
||||||
prompt: null,
|
|
||||||
temperature: 0.0,
|
|
||||||
costPerMinute: 0.001,
|
|
||||||
costPerSecond: 0.000017,
|
|
||||||
isDefault: false,
|
|
||||||
isActive: true,
|
|
||||||
fallbackPriority: 4,
|
|
||||||
extraHeadersJson: null,
|
|
||||||
latencyMs: 260,
|
|
||||||
createdAt: '2026-04-12T08:00:00Z',
|
|
||||||
updatedAt: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
name: 'AssemblyAI Universal-2',
|
|
||||||
providerType: 'assemblyai',
|
|
||||||
endpointUrl: 'https://api.assemblyai.com/v2/transcript',
|
|
||||||
apiKey: '••••••••',
|
|
||||||
modelId: 'best',
|
|
||||||
method: 'multipart',
|
|
||||||
language: 'ko',
|
|
||||||
prompt: null,
|
|
||||||
temperature: 0.0,
|
|
||||||
costPerMinute: 0.0025,
|
|
||||||
costPerSecond: 0.000042,
|
|
||||||
isDefault: false,
|
|
||||||
isActive: true,
|
|
||||||
fallbackPriority: 5,
|
|
||||||
extraHeadersJson: null,
|
|
||||||
latencyMs: 520,
|
|
||||||
createdAt: '2026-05-18T14:00:00Z',
|
|
||||||
updatedAt: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
name: 'Local Sidecar (Offline Faster-Whisper)',
|
|
||||||
providerType: 'local-sidecar',
|
|
||||||
endpointUrl: 'http://localhost:8971/stt/transcribe',
|
|
||||||
apiKey: '',
|
|
||||||
modelId: 'whisper-large-v3-turbo',
|
|
||||||
method: 'multipart',
|
|
||||||
language: 'ko',
|
|
||||||
prompt: null,
|
|
||||||
temperature: 0.0,
|
|
||||||
costPerMinute: 0.0,
|
|
||||||
costPerSecond: 0.0,
|
|
||||||
isDefault: false,
|
|
||||||
isActive: true,
|
|
||||||
fallbackPriority: 6,
|
|
||||||
extraHeadersJson: null,
|
|
||||||
latencyMs: 142,
|
|
||||||
createdAt: '2026-01-15T09:00:00Z',
|
|
||||||
updatedAt: null,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const MOCK_STT_USAGE_REPORT: SttUsageReport = {
|
|
||||||
totalTranscriptions: 88420,
|
|
||||||
totalAudioMinutes: 14820.5,
|
|
||||||
totalCost: 7.41,
|
|
||||||
avgLatencyMs: 165.4,
|
|
||||||
providerSummaries: [
|
|
||||||
{ provider: 'groq', modelId: 'whisper-large-v3-turbo', totalRequests: 74200, totalAudioMinutes: 12400.0, totalCost: 6.20, avgLatencyMs: 142.0 },
|
|
||||||
{ provider: 'openai', modelId: 'whisper-1', totalRequests: 8400, totalAudioMinutes: 1420.5, totalCost: 8.52, avgLatencyMs: 380.0 },
|
|
||||||
{ provider: 'deepgram', modelId: 'nova-3', totalRequests: 5820, totalAudioMinutes: 1000.0, totalCost: 4.30, avgLatencyMs: 195.0 },
|
|
||||||
],
|
|
||||||
userSummaries: [
|
|
||||||
{ userId: 1, email: 'admin@d3ro.voice', totalRequests: 14200, totalAudioMinutes: 2480.0, totalCost: 1.24 },
|
|
||||||
{ userId: 2, email: 'sarah.kim@techcorp.io', totalRequests: 18420, totalAudioMinutes: 3200.0, totalCost: 1.60 },
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSttEndpoints(): Promise<SttProviderEndpoint[]> {
|
export async function fetchSttEndpoints(): Promise<SttProviderEndpoint[]> {
|
||||||
try {
|
const data = await readJson<SttProviderEndpoint[]>('/stt-endpoints')
|
||||||
const res = await fetch(`${API_BASE}/api/admin/stt-endpoints`, { cache: 'no-store' })
|
if (!Array.isArray(data)) throw new Error('STT endpoints response is invalid')
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
return data
|
||||||
const data = await res.json()
|
|
||||||
return Array.isArray(data) && data.length > 0 ? data : MOCK_STT_ENDPOINTS
|
|
||||||
} catch {
|
|
||||||
return MOCK_STT_ENDPOINTS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createSttEndpoint(dto: CreateSttEndpointDto): Promise<SttProviderEndpoint> {
|
|
||||||
const res = await fetch(`${API_BASE}/api/admin/stt-endpoints`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(dto),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`Failed to create STT endpoint: ${res.statusText}`)
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateSttEndpoint(id: number, dto: UpdateSttEndpointDto): Promise<SttProviderEndpoint> {
|
|
||||||
const res = await fetch(`${API_BASE}/api/admin/stt-endpoints/${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(dto),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`Failed to update STT endpoint: ${res.statusText}`)
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteSttEndpoint(id: number): Promise<boolean> {
|
|
||||||
const res = await fetch(`${API_BASE}/api/admin/stt-endpoints/${id}`, { method: 'DELETE' })
|
|
||||||
return res.ok
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setDefaultSttEndpoint(id: number): Promise<boolean> {
|
|
||||||
const res = await fetch(`${API_BASE}/api/admin/stt-endpoints/${id}/set-default`, { method: 'POST' })
|
|
||||||
return res.ok
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function testSttEndpoint(id: number, apiKey?: string, endpointUrl?: string): Promise<SttTestResult> {
|
|
||||||
try {
|
|
||||||
let url = `${API_BASE}/api/admin/stt-endpoints/${id}/test`
|
|
||||||
if (id === 0 && endpointUrl) {
|
|
||||||
url = `${API_BASE}/api/admin/stt-endpoints/test-direct?endpointUrl=${encodeURIComponent(endpointUrl)}&apiKey=${encodeURIComponent(apiKey || '')}`
|
|
||||||
}
|
|
||||||
const res = await fetch(url, { method: 'POST' })
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
||||||
return await res.json()
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: err instanceof Error ? err.message : 'Connection test failed',
|
|
||||||
latencyMs: 0,
|
|
||||||
transcriptPreview: null,
|
|
||||||
provider: null,
|
|
||||||
modelId: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSttUsageReport(): Promise<SttUsageReport> {
|
export async function fetchSttUsageReport(): Promise<SttUsageReport> {
|
||||||
try {
|
return readJson<SttUsageReport>('/stt-usage')
|
||||||
const res = await fetch(`${API_BASE}/api/admin/stt-usage`, { cache: 'no-store' })
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
||||||
return await res.json()
|
|
||||||
} catch {
|
|
||||||
return MOCK_STT_USAGE_REPORT
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchUsageReport(): Promise<UsageReport> {
|
export async function fetchUsageReport(): Promise<UsageReport> {
|
||||||
try {
|
const data = await readJson<Omit<UsageReport, 'timeline' | 'features'> & Partial<Pick<UsageReport, 'timeline' | 'features'>>>('/usage')
|
||||||
const res = await fetch(`${API_BASE}/api/admin/usage`, { cache: 'no-store' })
|
return { ...data, timeline: Array.isArray(data.timeline) ? data.timeline : [], features: Array.isArray(data.features) ? data.features : [] }
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
||||||
const data = await res.json()
|
|
||||||
return {
|
|
||||||
totalRequests: data.totalRequests ?? MOCK_USAGE_REPORT.totalRequests,
|
|
||||||
totalPromptTokens: data.totalPromptTokens ?? MOCK_USAGE_REPORT.totalPromptTokens,
|
|
||||||
totalCompletionTokens: data.totalCompletionTokens ?? MOCK_USAGE_REPORT.totalCompletionTokens,
|
|
||||||
totalCost: data.totalCost ?? MOCK_USAGE_REPORT.totalCost,
|
|
||||||
timeline: MOCK_USAGE_REPORT.timeline,
|
|
||||||
features: MOCK_USAGE_REPORT.features,
|
|
||||||
userSummaries: data.userSummaries && data.userSummaries.length > 0 ? data.userSummaries : MOCK_USAGE_REPORT.userSummaries,
|
|
||||||
modelSummaries: data.modelSummaries && data.modelSummaries.length > 0 ? data.modelSummaries : MOCK_USAGE_REPORT.modelSummaries,
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return MOCK_USAGE_REPORT
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
35
apps/admin/src/lib/audit-sanitize.ts
Normal file
35
apps/admin/src/lib/audit-sanitize.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import 'server-only'
|
||||||
|
|
||||||
|
const SENSITIVE_KEYS = new Set([
|
||||||
|
'api_key',
|
||||||
|
'password',
|
||||||
|
'payload',
|
||||||
|
'payload_digest',
|
||||||
|
'payple_payer_id',
|
||||||
|
'payple_pay_oid',
|
||||||
|
'provider_event_id',
|
||||||
|
'provider_resource_id',
|
||||||
|
'raw_payload',
|
||||||
|
'secret',
|
||||||
|
'token'
|
||||||
|
])
|
||||||
|
|
||||||
|
function sanitizeValue(value: unknown, depth: number): unknown {
|
||||||
|
if (depth > 8) return '[REDACTED]'
|
||||||
|
if (Array.isArray(value)) return value.map((item) => sanitizeValue(item, depth + 1))
|
||||||
|
if (!value || typeof value !== 'object') return value
|
||||||
|
|
||||||
|
const sanitized: Record<string, unknown> = {}
|
||||||
|
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
if (SENSITIVE_KEYS.has(key.toLowerCase())) continue
|
||||||
|
sanitized[key] = sanitizeValue(item, depth + 1)
|
||||||
|
}
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeAuditSnapshot(value: unknown): Record<string, unknown> | null {
|
||||||
|
const sanitized = sanitizeValue(value, 0)
|
||||||
|
return sanitized && typeof sanitized === 'object' && !Array.isArray(sanitized)
|
||||||
|
? sanitized as Record<string, unknown>
|
||||||
|
: null
|
||||||
|
}
|
||||||
36
apps/admin/src/lib/backend-admin-client.ts
Normal file
36
apps/admin/src/lib/backend-admin-client.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { CreateSttEndpointDto, ModelEndpoint, SttProviderEndpoint, SttTestResult, UpdateSttEndpointDto } from './api-server'
|
||||||
|
|
||||||
|
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const response = await fetch(`/api/admin/backend${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { Accept: 'application/json', ...(init.body ? { 'Content-Type': 'application/json' } : {}), ...(init.headers ?? {}) },
|
||||||
|
cache: 'no-store'
|
||||||
|
})
|
||||||
|
const payload = await response.json().catch(() => null) as { error?: string; message?: string } | null
|
||||||
|
if (!response.ok) throw new Error(payload?.error ?? payload?.message ?? `Admin request failed (${response.status})`)
|
||||||
|
return payload as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchModelEndpointsClient = (): Promise<ModelEndpoint[]> => request('/endpoints')
|
||||||
|
export const fetchSttEndpointsClient = (): Promise<SttProviderEndpoint[]> => request('/stt-endpoints')
|
||||||
|
function mutationHeaders(): Record<string, string> {
|
||||||
|
return { 'Idempotency-Key': crypto.randomUUID() }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createModelEndpointClient = (dto: { modelId: string; modelName: string; provider: string; endpointUrl: string; apiKey: string; costPer1kPromptTokens: number; costPer1kCompletionTokens: number; memo: string }): Promise<ModelEndpoint> => request('/endpoints', { method: 'POST', body: JSON.stringify(dto), headers: mutationHeaders() })
|
||||||
|
export const deleteModelEndpointClient = (id: number, memo: string): Promise<{ message: string }> => request(`/endpoints/${id}`, { method: 'DELETE', body: JSON.stringify({ memo }), headers: mutationHeaders() })
|
||||||
|
export const createSttEndpointClient = (dto: CreateSttEndpointDto): Promise<SttProviderEndpoint> => request('/stt-endpoints', { method: 'POST', body: JSON.stringify(dto), headers: mutationHeaders() })
|
||||||
|
export const updateSttEndpointClient = (id: number, dto: UpdateSttEndpointDto): Promise<SttProviderEndpoint> => request(`/stt-endpoints/${id}`, { method: 'PUT', body: JSON.stringify(dto), headers: mutationHeaders() })
|
||||||
|
export const deleteSttEndpointClient = (id: number, memo: string): Promise<{ message: string }> => request(`/stt-endpoints/${id}`, { method: 'DELETE', body: JSON.stringify({ memo }), headers: mutationHeaders() })
|
||||||
|
export const setDefaultSttEndpointClient = (id: number, memo: string): Promise<{ success: boolean }> => request(`/stt-endpoints/${id}/set-default`, { method: 'POST', body: JSON.stringify({ memo }), headers: mutationHeaders() })
|
||||||
|
export const testSttEndpointClient = (id: number, apiKey?: string, endpointUrl?: string): Promise<SttTestResult> => {
|
||||||
|
if (id === 0 && endpointUrl) {
|
||||||
|
return request('/stt-endpoints/test-direct', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ endpointUrl, apiKey: apiKey ?? null })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return request(`/stt-endpoints/${id}/test`, { method: 'POST' })
|
||||||
|
}
|
||||||
112
apps/admin/src/lib/backend-session.ts
Normal file
112
apps/admin/src/lib/backend-session.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import 'server-only'
|
||||||
|
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { verifySession } from './security'
|
||||||
|
import type { AdminRole, AdminSession } from './admin-session'
|
||||||
|
|
||||||
|
export class AdminBackendError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_LEVEL: Record<AdminRole, number> = {
|
||||||
|
manager: 1,
|
||||||
|
admin: 2,
|
||||||
|
super_admin: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRole(value: unknown): AdminRole | null {
|
||||||
|
if (typeof value !== 'string') return null
|
||||||
|
const normalized = value.replace(/[_-]/g, '').toLowerCase()
|
||||||
|
if (normalized === 'manager') return 'manager'
|
||||||
|
if (normalized === 'admin') return 'admin'
|
||||||
|
if (normalized === 'superadmin') return 'super_admin'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireApiServerOrigin(): string {
|
||||||
|
const configured = process.env.API_SERVER_URL?.trim()
|
||||||
|
if (!configured) throw new AdminBackendError('admin_backend_unavailable', 503)
|
||||||
|
|
||||||
|
let parsed: URL
|
||||||
|
try {
|
||||||
|
parsed = new URL(configured)
|
||||||
|
} catch {
|
||||||
|
throw new AdminBackendError('admin_backend_unavailable', 503)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') {
|
||||||
|
throw new AdminBackendError('admin_backend_unavailable', 503)
|
||||||
|
}
|
||||||
|
if (parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
||||||
|
throw new AdminBackendError('admin_backend_unavailable', 503)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.origin
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireVerifiedBackendSession(
|
||||||
|
minimumRole: AdminRole = 'manager'
|
||||||
|
): Promise<AdminSession> {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const signedSession = cookieStore.get('d3ro_admin_session')?.value
|
||||||
|
const session = signedSession ? verifySession(signedSession) : null
|
||||||
|
if (!session) throw new AdminBackendError('admin_session_invalid', 401)
|
||||||
|
if (ROLE_LEVEL[session.role] < ROLE_LEVEL[minimumRole]) {
|
||||||
|
throw new AdminBackendError('admin_forbidden', 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response
|
||||||
|
try {
|
||||||
|
response = await fetch(`${requireApiServerOrigin()}/api/auth/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${session.token}` },
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: AbortSignal.timeout(7_000)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
throw new AdminBackendError('admin_backend_unavailable', 503)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new AdminBackendError('admin_session_invalid', 401)
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new AdminBackendError('admin_backend_unavailable', 503)
|
||||||
|
|
||||||
|
let identity: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
identity = (await response.json()) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
throw new AdminBackendError('admin_backend_invalid_response', 502)
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = typeof identity.email === 'string' ? identity.email.trim().toLowerCase() : ''
|
||||||
|
const role = normalizeRole(identity.role)
|
||||||
|
if (email !== session.email || role !== session.role) {
|
||||||
|
throw new AdminBackendError('admin_session_mismatch', 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminBackend(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
minimumRole: AdminRole = 'manager'
|
||||||
|
): Promise<Response> {
|
||||||
|
const session = await requireVerifiedBackendSession(minimumRole)
|
||||||
|
return fetch(`${requireApiServerOrigin()}/api/admin${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${session.token}`,
|
||||||
|
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
...(init.headers ?? {})
|
||||||
|
},
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: init.signal ?? AbortSignal.timeout(10_000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -83,7 +83,7 @@ export const tableSx = {
|
||||||
'& th': {
|
'& th': {
|
||||||
px: 2,
|
px: 2,
|
||||||
pb: 1.5,
|
pb: 1.5,
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
textTransform: 'uppercase' as const,
|
textTransform: 'uppercase' as const,
|
||||||
letterSpacing: '0.08em',
|
letterSpacing: '0.08em',
|
||||||
|
|
@ -125,7 +125,7 @@ export const filterBtnSx = (active: boolean) => ({
|
||||||
borderRadius: '999px',
|
borderRadius: '999px',
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
letterSpacing: '0.04em',
|
letterSpacing: '0.04em',
|
||||||
textTransform: 'none' as const,
|
textTransform: 'none' as const,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
|
@ -166,7 +166,7 @@ export function statusBadgeSx(variant: 'green' | 'red' | 'orange' | 'blue' | 'pu
|
||||||
borderRadius: '999px',
|
borderRadius: '999px',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
letterSpacing: '0.04em',
|
letterSpacing: '0.04em',
|
||||||
bgcolor: c.bg,
|
bgcolor: c.bg,
|
||||||
color: c.fg,
|
color: c.fg,
|
||||||
|
|
@ -182,7 +182,7 @@ export const primaryButtonSx = {
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
fontFamily: FONT_SANS,
|
fontFamily: FONT_SANS,
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
fontWeight: 600,
|
fontWeight: 500,
|
||||||
borderRadius: '10px',
|
borderRadius: '10px',
|
||||||
px: 2.5,
|
px: 2.5,
|
||||||
py: 1,
|
py: 1,
|
||||||
|
|
|
||||||
40
apps/admin/src/lib/edge-session.ts
Normal file
40
apps/admin/src/lib/edge-session.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import {
|
||||||
|
decodeAdminSessionPayload,
|
||||||
|
decodeBase64Url,
|
||||||
|
requireAdminSessionSecret,
|
||||||
|
type AdminSession
|
||||||
|
} from './admin-session'
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
|
||||||
|
export async function verifyEdgeSession(token: string): Promise<AdminSession | null> {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.')
|
||||||
|
if (parts.length !== 2) return null
|
||||||
|
|
||||||
|
const [encodedPayload, encodedSignature] = parts
|
||||||
|
const signature = decodeBase64Url(encodedSignature)
|
||||||
|
if (!signature || signature.byteLength !== 32) return null
|
||||||
|
const signatureBuffer = new ArrayBuffer(signature.byteLength)
|
||||||
|
new Uint8Array(signatureBuffer).set(signature)
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(requireAdminSessionSecret()),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['verify']
|
||||||
|
)
|
||||||
|
const verified = await crypto.subtle.verify(
|
||||||
|
'HMAC',
|
||||||
|
key,
|
||||||
|
signatureBuffer,
|
||||||
|
encoder.encode(encodedPayload)
|
||||||
|
)
|
||||||
|
if (!verified) return null
|
||||||
|
|
||||||
|
return decodeAdminSessionPayload(encodedPayload)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
148
apps/admin/src/lib/forgejo-releases.ts
Normal file
148
apps/admin/src/lib/forgejo-releases.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import 'server-only'
|
||||||
|
|
||||||
|
export type ReleaseAssetPlatform = 'windows' | 'macos' | 'android' | 'linux' | 'feed' | 'other'
|
||||||
|
|
||||||
|
export interface ReleaseAsset {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
platform: ReleaseAssetPlatform
|
||||||
|
sizeBytes: number
|
||||||
|
downloadCount: number
|
||||||
|
browserDownloadUrl: string
|
||||||
|
createdAt: string
|
||||||
|
sha256: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseEntry {
|
||||||
|
id: number
|
||||||
|
tagName: string
|
||||||
|
name: string
|
||||||
|
publishedAt: string
|
||||||
|
isPrerelease: boolean
|
||||||
|
isDraft: boolean
|
||||||
|
htmlUrl: string
|
||||||
|
assets: ReleaseAsset[]
|
||||||
|
downloadCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReleaseHub {
|
||||||
|
repoHtmlUrl: string
|
||||||
|
releases: ReleaseEntry[]
|
||||||
|
latestStable: ReleaseEntry | null
|
||||||
|
totalDownloads: number
|
||||||
|
prereleaseCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_REPO_URL = 'https://git.chanpaca.net/yunchan/d3ro-voice'
|
||||||
|
|
||||||
|
export function releaseRepoHtmlUrl(): string {
|
||||||
|
const configured = process.env.RELEASE_REPO_URL?.trim()
|
||||||
|
const value = configured || DEFAULT_REPO_URL
|
||||||
|
const parsed = new URL(value)
|
||||||
|
if (parsed.protocol !== 'https:') throw new Error('Release repository URL must use HTTPS')
|
||||||
|
const segments = parsed.pathname.split('/').filter(Boolean)
|
||||||
|
if (segments.length !== 2) throw new Error('Release repository URL must point to owner/repo')
|
||||||
|
return `${parsed.origin}/${segments[0]}/${segments[1]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseApiUrl(): string {
|
||||||
|
const html = new URL(releaseRepoHtmlUrl())
|
||||||
|
return `${html.origin}/api/v1/repos${html.pathname}/releases`
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferPlatform(assetName: string): ReleaseAssetPlatform {
|
||||||
|
const name = assetName.toLowerCase()
|
||||||
|
if (name.endsWith('.yml') || name.endsWith('.yaml')) return 'feed'
|
||||||
|
if (name.endsWith('.exe') || name.endsWith('.exe.blockmap') || name.endsWith('.msi') || name.endsWith('.nupkg')) {
|
||||||
|
return 'windows'
|
||||||
|
}
|
||||||
|
if (name.endsWith('.dmg') || name.includes('mac') || name.includes('darwin')) return 'macos'
|
||||||
|
if (name.endsWith('.apk') || name.endsWith('.aab')) return 'android'
|
||||||
|
if (name.endsWith('.appimage') || name.endsWith('.deb') || name.endsWith('.rpm')) return 'linux'
|
||||||
|
return 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 릴리스 본문에서 `파일명`: `64자 hex` 형태의 SHA-256 표기를 수집한다. */
|
||||||
|
function parseChecksums(body: unknown): Map<string, string> {
|
||||||
|
const checksums = new Map<string, string>()
|
||||||
|
if (typeof body !== 'string' || !body) return checksums
|
||||||
|
const pattern = /`([^`\n]+)`\s*[::]\s*`([0-9a-fA-F]{64})`/g
|
||||||
|
for (const match of body.matchAll(pattern)) {
|
||||||
|
checksums.set(match[1].trim(), match[2].toLowerCase())
|
||||||
|
}
|
||||||
|
return checksums
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAsset(value: Record<string, unknown>, checksums: Map<string, string>): ReleaseAsset | null {
|
||||||
|
const id = typeof value.id === 'number' ? value.id : null
|
||||||
|
const name = typeof value.name === 'string' ? value.name : ''
|
||||||
|
const downloadUrl = typeof value.browser_download_url === 'string' ? value.browser_download_url : ''
|
||||||
|
if (id === null || !name || !downloadUrl) return null
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
platform: inferPlatform(name),
|
||||||
|
sizeBytes: typeof value.size === 'number' ? value.size : 0,
|
||||||
|
downloadCount: typeof value.download_count === 'number' ? value.download_count : 0,
|
||||||
|
browserDownloadUrl: downloadUrl,
|
||||||
|
createdAt: typeof value.created_at === 'string' ? value.created_at : '',
|
||||||
|
sha256: checksums.get(name) ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRelease(value: unknown): ReleaseEntry | null {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||||
|
const record = value as Record<string, unknown>
|
||||||
|
const id = typeof record.id === 'number' ? record.id : null
|
||||||
|
const tagName = typeof record.tag_name === 'string' ? record.tag_name : ''
|
||||||
|
const htmlUrl = typeof record.html_url === 'string' ? record.html_url : ''
|
||||||
|
if (id === null || !tagName || !htmlUrl) return null
|
||||||
|
|
||||||
|
const checksums = parseChecksums(record.body)
|
||||||
|
const rawAssets = Array.isArray(record.assets) ? record.assets : []
|
||||||
|
const assets = rawAssets
|
||||||
|
.filter((asset): asset is Record<string, unknown> => !!asset && typeof asset === 'object')
|
||||||
|
.map((asset) => parseAsset(asset, checksums))
|
||||||
|
.filter((asset): asset is ReleaseAsset => asset !== null)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
tagName,
|
||||||
|
name: typeof record.name === 'string' && record.name.trim() ? record.name : tagName,
|
||||||
|
publishedAt: typeof record.published_at === 'string' ? record.published_at : '',
|
||||||
|
isPrerelease: record.prerelease === true,
|
||||||
|
isDraft: record.draft === true,
|
||||||
|
htmlUrl,
|
||||||
|
assets,
|
||||||
|
downloadCount: assets.reduce((sum, asset) => sum + asset.downloadCount, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchReleaseHub(): Promise<ReleaseHub> {
|
||||||
|
const response = await fetch(`${releaseApiUrl()}?limit=30`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: AbortSignal.timeout(8_000)
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`Forgejo release feed request failed (${response.status})`)
|
||||||
|
|
||||||
|
let payload: unknown
|
||||||
|
try {
|
||||||
|
payload = await response.json()
|
||||||
|
} catch {
|
||||||
|
throw new Error('Forgejo release feed returned invalid JSON')
|
||||||
|
}
|
||||||
|
if (!Array.isArray(payload)) throw new Error('Forgejo release feed response is invalid')
|
||||||
|
|
||||||
|
const releases = payload
|
||||||
|
.map(parseRelease)
|
||||||
|
.filter((release): release is ReleaseEntry => release !== null && !release.isDraft)
|
||||||
|
|
||||||
|
return {
|
||||||
|
repoHtmlUrl: releaseRepoHtmlUrl(),
|
||||||
|
releases,
|
||||||
|
latestStable: releases.find((release) => !release.isPrerelease) ?? releases[0] ?? null,
|
||||||
|
totalDownloads: releases.reduce((sum, release) => sum + release.downloadCount, 0),
|
||||||
|
prereleaseCount: releases.filter((release) => release.isPrerelease).length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
// apps/admin/src/lib/security.ts
|
// apps/admin/src/lib/security.ts
|
||||||
// D3RO Voice — Military-Grade Admin Security & Rate-Limiting Engine
|
// D3RO Voice — Military-Grade Admin Security & Rate-Limiting Engine
|
||||||
|
|
||||||
import crypto from 'crypto'
|
import 'server-only'
|
||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||||
|
import {
|
||||||
|
decodeAdminSessionPayload,
|
||||||
|
requireAdminSessionSecret,
|
||||||
|
type AdminSession
|
||||||
|
} from './admin-session'
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'D3ROVoice_Super_Secure_Secret_Key_2026_Key!'
|
|
||||||
const MAX_FAILED_ATTEMPTS = 5
|
const MAX_FAILED_ATTEMPTS = 5
|
||||||
const LOCKOUT_DURATION_MS = 15 * 60 * 1000 // 15 minutes lockout
|
const LOCKOUT_DURATION_MS = 15 * 60 * 1000 // 15 minutes lockout
|
||||||
const WINDOW_DURATION_MS = 5 * 60 * 1000 // 5 minutes attempt window
|
const WINDOW_DURATION_MS = 5 * 60 * 1000 // 5 minutes attempt window
|
||||||
|
|
@ -45,7 +50,10 @@ export function checkRateLimit(clientKey: string): { allowed: boolean; retryAfte
|
||||||
/**
|
/**
|
||||||
* Records a failed login attempt and locks the client if threshold is exceeded.
|
* Records a failed login attempt and locks the client if threshold is exceeded.
|
||||||
*/
|
*/
|
||||||
export function recordFailedAttempt(clientKey: string): { locked: boolean; retryAfterSeconds: number } {
|
export function recordFailedAttempt(clientKey: string): {
|
||||||
|
locked: boolean
|
||||||
|
retryAfterSeconds: number
|
||||||
|
} {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const record = failedAttemptsMap.get(clientKey)
|
const record = failedAttemptsMap.get(clientKey)
|
||||||
|
|
||||||
|
|
@ -53,7 +61,7 @@ export function recordFailedAttempt(clientKey: string): { locked: boolean; retry
|
||||||
failedAttemptsMap.set(clientKey, {
|
failedAttemptsMap.set(clientKey, {
|
||||||
count: 1,
|
count: 1,
|
||||||
firstAttemptAt: now,
|
firstAttemptAt: now,
|
||||||
lockedUntil: null,
|
lockedUntil: null
|
||||||
})
|
})
|
||||||
return { locked: false, retryAfterSeconds: 0 }
|
return { locked: false, retryAfterSeconds: 0 }
|
||||||
}
|
}
|
||||||
|
|
@ -79,10 +87,11 @@ export function resetFailedAttempts(clientKey: string): void {
|
||||||
/**
|
/**
|
||||||
* Cryptographically signs a session payload with HMAC-SHA256.
|
* Cryptographically signs a session payload with HMAC-SHA256.
|
||||||
*/
|
*/
|
||||||
export function signSession(payload: Record<string, unknown>): string {
|
export function signSession(payload: AdminSession): string {
|
||||||
|
const sessionSecret = requireAdminSessionSecret()
|
||||||
const jsonStr = JSON.stringify(payload)
|
const jsonStr = JSON.stringify(payload)
|
||||||
const encodedPayload = Buffer.from(jsonStr).toString('base64url')
|
const encodedPayload = Buffer.from(jsonStr).toString('base64url')
|
||||||
const hmac = crypto.createHmac('sha256', JWT_SECRET)
|
const hmac = createHmac('sha256', sessionSecret)
|
||||||
hmac.update(encodedPayload)
|
hmac.update(encodedPayload)
|
||||||
const signature = hmac.digest('base64url')
|
const signature = hmac.digest('base64url')
|
||||||
return `${encodedPayload}.${signature}`
|
return `${encodedPayload}.${signature}`
|
||||||
|
|
@ -92,31 +101,30 @@ export function signSession(payload: Record<string, unknown>): string {
|
||||||
* Verifies and decodes a cryptographically signed session token.
|
* Verifies and decodes a cryptographically signed session token.
|
||||||
* Uses timingSafeEqual to prevent timing attacks.
|
* Uses timingSafeEqual to prevent timing attacks.
|
||||||
*/
|
*/
|
||||||
export function verifySession<T = Record<string, unknown>>(tokenString: string): T | null {
|
export function verifySession(tokenString: string): AdminSession | null {
|
||||||
try {
|
try {
|
||||||
|
const sessionSecret = requireAdminSessionSecret()
|
||||||
const parts = tokenString.split('.')
|
const parts = tokenString.split('.')
|
||||||
if (parts.length !== 2) return null
|
if (parts.length !== 2) return null
|
||||||
|
|
||||||
const [encodedPayload, providedSignature] = parts
|
const [encodedPayload, providedSignature] = parts
|
||||||
const hmac = crypto.createHmac('sha256', JWT_SECRET)
|
if (!/^[A-Za-z0-9_-]+$/.test(providedSignature)) return null
|
||||||
hmac.update(encodedPayload)
|
const providedBuf = Buffer.from(providedSignature, 'base64url')
|
||||||
const expectedSignature = hmac.digest('base64url')
|
if (providedBuf.length !== 32) return null
|
||||||
|
|
||||||
const providedBuf = Buffer.from(providedSignature)
|
const hmac = createHmac('sha256', sessionSecret)
|
||||||
const expectedBuf = Buffer.from(expectedSignature)
|
hmac.update(encodedPayload)
|
||||||
|
const expectedBuf = hmac.digest()
|
||||||
|
|
||||||
if (providedBuf.length !== expectedBuf.length) return null
|
if (providedBuf.length !== expectedBuf.length) return null
|
||||||
if (!crypto.timingSafeEqual(providedBuf, expectedBuf)) return null
|
if (!timingSafeEqual(providedBuf, expectedBuf)) return null
|
||||||
|
|
||||||
const jsonStr = Buffer.from(encodedPayload, 'base64url').toString('utf-8')
|
return decodeAdminSessionPayload(encodedPayload)
|
||||||
const parsed = JSON.parse(jsonStr) as T & { expiresAt?: number }
|
|
||||||
|
|
||||||
if (parsed.expiresAt && parsed.expiresAt <= Date.now()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateAdminRuntimeSecurity(): void {
|
||||||
|
requireAdminSessionSecret()
|
||||||
|
}
|
||||||
|
|
|
||||||
57
apps/admin/src/lib/subscription-metrics.ts
Normal file
57
apps/admin/src/lib/subscription-metrics.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import 'server-only'
|
||||||
|
|
||||||
|
import { getSupabaseAdminClient } from './supabase-admin'
|
||||||
|
|
||||||
|
/** Monthly list price per tier, in USD. */
|
||||||
|
const TIER_MONTHLY_USD: Record<'pro' | 'pro_plus' | 'free', number> = {
|
||||||
|
pro: 9.9,
|
||||||
|
pro_plus: 19.9,
|
||||||
|
free: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionRevenue {
|
||||||
|
mrrUsd: number
|
||||||
|
arrUsd: number
|
||||||
|
activeCount: number
|
||||||
|
tierBreakdown: { pro: number; pro_plus: number; free: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregates real subscription revenue from Supabase.
|
||||||
|
* Only status === 'active' subscriptions contribute to MRR/ARR.
|
||||||
|
* MRR = Σ(monthly price of each active subscription's tier); ARR = MRR * 12.
|
||||||
|
*/
|
||||||
|
export async function fetchSubscriptionRevenue(): Promise<SubscriptionRevenue> {
|
||||||
|
const supabase = await getSupabaseAdminClient('manager')
|
||||||
|
|
||||||
|
const rows: Array<Record<string, unknown>> = []
|
||||||
|
for (let page = 0; page < 100; page += 1) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('subscriptions')
|
||||||
|
.select('tier, status')
|
||||||
|
.range(page * 1000, page * 1000 + 999)
|
||||||
|
if (error) throw new Error('Supabase subscription revenue query failed')
|
||||||
|
rows.push(...((data ?? []) as Array<Record<string, unknown>>))
|
||||||
|
if ((data?.length ?? 0) < 1000) break
|
||||||
|
if (page === 99) throw new Error('Supabase subscription directory exceeds the supported administrative window')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tierBreakdown = { pro: 0, pro_plus: 0, free: 0 }
|
||||||
|
let mrrUsd = 0
|
||||||
|
let activeCount = 0
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.status !== 'active') continue
|
||||||
|
activeCount += 1
|
||||||
|
const tier = row.tier
|
||||||
|
if (tier === 'pro' || tier === 'pro_plus' || tier === 'free') {
|
||||||
|
tierBreakdown[tier] += 1
|
||||||
|
mrrUsd += TIER_MONTHLY_USD[tier]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mrrUsd = Math.round(mrrUsd * 100) / 100
|
||||||
|
const arrUsd = Math.round(mrrUsd * 12 * 100) / 100
|
||||||
|
|
||||||
|
return { mrrUsd, arrUsd, activeCount, tierBreakdown }
|
||||||
|
}
|
||||||
106
apps/admin/src/lib/supabase-admin.ts
Normal file
106
apps/admin/src/lib/supabase-admin.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import 'server-only'
|
||||||
|
|
||||||
|
import { createClient, type SupabaseClient, type User as AuthUser } from '@supabase/supabase-js'
|
||||||
|
import { AdminBackendError, requireVerifiedBackendSession } from './backend-session'
|
||||||
|
import type { AdminRole } from './admin-session'
|
||||||
|
import type { UserItem } from './api-server'
|
||||||
|
|
||||||
|
export function isSupabaseAdminConfigured(): boolean {
|
||||||
|
const url = (process.env.SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL)?.trim()
|
||||||
|
return Boolean(url && process.env.SUPABASE_SERVICE_ROLE_KEY?.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireSupabaseConfiguration(): { url: string; serviceRoleKey: string } {
|
||||||
|
const url = (process.env.SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL)?.trim()
|
||||||
|
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim()
|
||||||
|
if (!url || !serviceRoleKey) throw new Error('Supabase admin integration is not configured')
|
||||||
|
|
||||||
|
const parsed = new URL(url)
|
||||||
|
const developmentLocalHost =
|
||||||
|
process.env.NODE_ENV !== 'production' &&
|
||||||
|
['127.0.0.1', 'localhost', 'host.docker.internal'].includes(parsed.hostname)
|
||||||
|
if (parsed.protocol !== 'https:' && !developmentLocalHost) {
|
||||||
|
throw new Error('Supabase admin URL must use HTTPS')
|
||||||
|
}
|
||||||
|
return { url: parsed.origin, serviceRoleKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSupabaseAdminClient(minimumRole: AdminRole = 'manager'): Promise<SupabaseClient> {
|
||||||
|
const session = await requireVerifiedBackendSession(minimumRole)
|
||||||
|
const { url, serviceRoleKey } = requireSupabaseConfiguration()
|
||||||
|
const client = createClient(url, serviceRoleKey, {
|
||||||
|
auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false },
|
||||||
|
global: { headers: { 'X-Client-Info': 'd3ro-admin-server' } }
|
||||||
|
})
|
||||||
|
const { data, error } = await client.rpc('resolve_external_admin_actor_v1', {
|
||||||
|
p_email: session.email,
|
||||||
|
p_minimum_role: minimumRole
|
||||||
|
})
|
||||||
|
if (error) {
|
||||||
|
if (error.code === '42501') throw new AdminBackendError('admin_supabase_identity_forbidden', 403)
|
||||||
|
throw new AdminBackendError('admin_supabase_identity_unavailable', 503)
|
||||||
|
}
|
||||||
|
const actor = Array.isArray(data) ? data[0] as Record<string, unknown> | undefined : undefined
|
||||||
|
if (!actor || typeof actor.actor_id !== 'string' || typeof actor.actor_role !== 'string') {
|
||||||
|
throw new AdminBackendError('admin_supabase_identity_invalid', 502)
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActiveAuthUser(user: AuthUser): boolean {
|
||||||
|
if (!user.banned_until) return true
|
||||||
|
return Date.parse(user.banned_until) <= Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProductUsers(): Promise<UserItem[]> {
|
||||||
|
const supabase = await getSupabaseAdminClient()
|
||||||
|
const authUsers: AuthUser[] = []
|
||||||
|
for (let page = 1; page <= 100; page += 1) {
|
||||||
|
const { data, error } = await supabase.auth.admin.listUsers({ page, perPage: 1000 })
|
||||||
|
if (error) throw new Error('Supabase user directory failed')
|
||||||
|
authUsers.push(...data.users)
|
||||||
|
if (data.users.length < 1000) break
|
||||||
|
if (page === 100) throw new Error('Supabase user directory exceeds the supported administrative window')
|
||||||
|
}
|
||||||
|
|
||||||
|
const userIds = authUsers.map((user) => user.id)
|
||||||
|
if (userIds.length === 0) return []
|
||||||
|
|
||||||
|
const idChunks: string[][] = []
|
||||||
|
for (let index = 0; index < userIds.length; index += 200) idChunks.push(userIds.slice(index, index + 200))
|
||||||
|
const relatedRows = await Promise.all(idChunks.map(async (ids) => {
|
||||||
|
const [profiles, subscriptions] = await Promise.all([
|
||||||
|
supabase.from('profiles').select('id, name, tier, role').in('id', ids),
|
||||||
|
supabase.from('subscriptions').select('user_id, tier').in('user_id', ids)
|
||||||
|
])
|
||||||
|
if (profiles.error || subscriptions.error) throw new Error('Supabase user account relations failed')
|
||||||
|
return { profiles: profiles.data ?? [], subscriptions: subscriptions.data ?? [] }
|
||||||
|
}))
|
||||||
|
const profiles = relatedRows.flatMap((rows) => rows.profiles)
|
||||||
|
const subscriptions = relatedRows.flatMap((rows) => rows.subscriptions)
|
||||||
|
|
||||||
|
const profileMap = new Map((profiles ?? []).map((profile) => [profile.id as string, profile]))
|
||||||
|
const subscriptionMap = new Map((subscriptions ?? []).map((subscription) => [subscription.user_id as string, subscription]))
|
||||||
|
|
||||||
|
return authUsers.map((user) => {
|
||||||
|
const profile = profileMap.get(user.id)
|
||||||
|
const subscription = subscriptionMap.get(user.id)
|
||||||
|
const role = profile?.role
|
||||||
|
const normalizedRole: UserItem['role'] = role === 'manager' || role === 'admin' || role === 'super_admin' ? role : 'user'
|
||||||
|
const tier = subscription?.tier ?? profile?.tier
|
||||||
|
const normalizedTier: UserItem['tier'] = tier === 'pro' || tier === 'pro_plus' || tier === 'free' ? tier : null
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
uid: user.id,
|
||||||
|
email: user.email ?? '',
|
||||||
|
name: typeof profile?.name === 'string' && profile.name.trim() ? profile.name : user.email ?? user.id,
|
||||||
|
role: normalizedRole,
|
||||||
|
tier: normalizedTier,
|
||||||
|
createdAt: user.created_at,
|
||||||
|
lastLoginAt: user.last_sign_in_at ?? null,
|
||||||
|
lastActiveDevice: null,
|
||||||
|
isActive: isActiveAuthUser(user),
|
||||||
|
dailyUsage: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
// apps/admin/src/middleware.ts
|
|
||||||
// D3RO Voice — Industrial Grade Admin Route & Security Guard
|
|
||||||
|
|
||||||
import { NextResponse, type NextRequest } from 'next/server'
|
|
||||||
|
|
||||||
const PUBLIC_PATHS = ['/login', '/auth/callback', '/api/auth/login', '/api/auth/logout', '/favicon.ico', '/robots.txt']
|
|
||||||
|
|
||||||
export async function middleware(request: NextRequest): Promise<NextResponse> {
|
|
||||||
const { pathname } = request.nextUrl
|
|
||||||
|
|
||||||
// 1. Check if path is public (e.g. login, static assets)
|
|
||||||
const isPublic = PUBLIC_PATHS.some((path) => pathname === path || pathname.startsWith(path + '/'))
|
|
||||||
const isStatic = pathname.startsWith('/_next') || pathname.startsWith('/static') || pathname.includes('.')
|
|
||||||
|
|
||||||
// 2. Validate session cookie
|
|
||||||
const sessionCookie = request.cookies.get('d3ro_admin_session')?.value
|
|
||||||
let isAuthenticated = false
|
|
||||||
|
|
||||||
if (sessionCookie) {
|
|
||||||
try {
|
|
||||||
const decoded = JSON.parse(Buffer.from(sessionCookie, 'base64').toString('utf-8'))
|
|
||||||
if (decoded && decoded.expiresAt && decoded.expiresAt > Date.now()) {
|
|
||||||
isAuthenticated = true
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
isAuthenticated = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Unauthenticated access to protected route -> Redirect to /login
|
|
||||||
if (!isAuthenticated && !isPublic && !isStatic) {
|
|
||||||
const loginUrl = new URL('/login', request.url)
|
|
||||||
if (pathname !== '/') {
|
|
||||||
loginUrl.searchParams.set('redirect', pathname)
|
|
||||||
}
|
|
||||||
const redirectResponse = NextResponse.redirect(loginUrl)
|
|
||||||
addSecurityHeaders(redirectResponse)
|
|
||||||
return redirectResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Authenticated user visiting /login -> Redirect to Dashboard /
|
|
||||||
if (isAuthenticated && pathname === '/login') {
|
|
||||||
const dashboardUrl = new URL('/', request.url)
|
|
||||||
const redirectResponse = NextResponse.redirect(dashboardUrl)
|
|
||||||
addSecurityHeaders(redirectResponse)
|
|
||||||
return redirectResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Proceed with Security Headers attached
|
|
||||||
const response = NextResponse.next({ request: { headers: request.headers } })
|
|
||||||
addSecurityHeaders(response)
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSecurityHeaders(response: NextResponse): void {
|
|
||||||
// Anti-Crawling & Anti-Reconnaissance (Shodan, Google, Bing, AI scrapers)
|
|
||||||
response.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, nosnippet, noimageindex')
|
|
||||||
// Clickjacking Prevention
|
|
||||||
response.headers.set('X-Frame-Options', 'DENY')
|
|
||||||
// MIME Sniffing Prevention
|
|
||||||
response.headers.set('X-Content-Type-Options', 'nosniff')
|
|
||||||
// Referrer Privacy
|
|
||||||
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
|
|
||||||
// Feature Policy
|
|
||||||
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
|
||||||
}
|
|
||||||
53
apps/admin/src/proxy.ts
Normal file
53
apps/admin/src/proxy.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
// D3RO Voice — Industrial Grade Admin Route & Security Guard
|
||||||
|
|
||||||
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
|
import { verifyEdgeSession } from '@/lib/edge-session'
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = [
|
||||||
|
'/login',
|
||||||
|
'/auth/callback',
|
||||||
|
'/api/auth/login',
|
||||||
|
'/api/auth/logout',
|
||||||
|
'/favicon.ico',
|
||||||
|
'/robots.txt'
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function proxy(request: NextRequest): Promise<NextResponse> {
|
||||||
|
const { pathname } = request.nextUrl
|
||||||
|
const isPublic = PUBLIC_PATHS.some((path) => pathname === path || pathname.startsWith(path + '/'))
|
||||||
|
const isStatic =
|
||||||
|
pathname.startsWith('/_next') || pathname.startsWith('/static') || pathname.includes('.')
|
||||||
|
|
||||||
|
const sessionCookie = request.cookies.get('d3ro_admin_session')?.value
|
||||||
|
const isAuthenticated = sessionCookie ? (await verifyEdgeSession(sessionCookie)) !== null : false
|
||||||
|
|
||||||
|
if (!isAuthenticated && !isPublic && !isStatic) {
|
||||||
|
const loginUrl = new URL('/login', request.url)
|
||||||
|
if (pathname !== '/') loginUrl.searchParams.set('redirect', pathname)
|
||||||
|
const redirectResponse = NextResponse.redirect(loginUrl)
|
||||||
|
addSecurityHeaders(redirectResponse)
|
||||||
|
return redirectResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated && pathname === '/login') {
|
||||||
|
const redirectResponse = NextResponse.redirect(new URL('/', request.url))
|
||||||
|
addSecurityHeaders(redirectResponse)
|
||||||
|
return redirectResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = NextResponse.next({ request: { headers: request.headers } })
|
||||||
|
addSecurityHeaders(response)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSecurityHeaders(response: NextResponse): void {
|
||||||
|
response.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, nosnippet, noimageindex')
|
||||||
|
response.headers.set('X-Frame-Options', 'DENY')
|
||||||
|
response.headers.set('X-Content-Type-Options', 'nosniff')
|
||||||
|
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
|
||||||
|
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
|
||||||
|
}
|
||||||
23
apps/admin/start.mjs
Normal file
23
apps/admin/start.mjs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
function fail(message) {
|
||||||
|
console.error(`D3RO admin startup refused: ${message}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionSecret = process.env.ADMIN_SESSION_SECRET?.trim() ?? ''
|
||||||
|
if (new TextEncoder().encode(sessionSecret).byteLength < 32) {
|
||||||
|
fail('ADMIN_SESSION_SECRET must contain at least 32 bytes')
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiServerUrl = process.env.API_SERVER_URL?.trim() ?? ''
|
||||||
|
let parsedApiServerUrl
|
||||||
|
try {
|
||||||
|
parsedApiServerUrl = new URL(apiServerUrl)
|
||||||
|
} catch {
|
||||||
|
fail('API_SERVER_URL must be an absolute URL')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production' && parsedApiServerUrl.protocol !== 'https:') {
|
||||||
|
fail('API_SERVER_URL must use HTTPS in production')
|
||||||
|
}
|
||||||
|
|
||||||
|
await import('./server.js')
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using D3ROVoice.Api.Data;
|
using D3ROVoice.Api.Data;
|
||||||
using D3ROVoice.Api.Dtos;
|
using D3ROVoice.Api.Dtos;
|
||||||
|
|
@ -13,16 +15,19 @@ namespace D3ROVoice.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
|
[Authorize(Policy = "ManagerOrAbove")]
|
||||||
public class AdminController : ControllerBase
|
public class AdminController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly ISttProxyService _sttService;
|
private readonly ISttProxyService _sttService;
|
||||||
|
private readonly IAdminOperationService _adminOperations;
|
||||||
private static readonly DateTime _serverStartTime = DateTime.UtcNow;
|
private static readonly DateTime _serverStartTime = DateTime.UtcNow;
|
||||||
|
|
||||||
public AdminController(AppDbContext db, ISttProxyService sttService)
|
public AdminController(AppDbContext db, ISttProxyService sttService, IAdminOperationService adminOperations)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_sttService = sttService;
|
_sttService = sttService;
|
||||||
|
_adminOperations = adminOperations;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("stats")]
|
[HttpGet("stats")]
|
||||||
|
|
@ -75,73 +80,121 @@ public class AdminController : ControllerBase
|
||||||
public async Task<IActionResult> GetEndpoints()
|
public async Task<IActionResult> GetEndpoints()
|
||||||
{
|
{
|
||||||
var endpoints = await _db.ModelEndpoints
|
var endpoints = await _db.ModelEndpoints
|
||||||
|
.AsNoTracking()
|
||||||
.OrderBy(m => m.Id)
|
.OrderBy(m => m.Id)
|
||||||
|
.Select(endpoint => new
|
||||||
|
{
|
||||||
|
endpoint.Id,
|
||||||
|
endpoint.ModelId,
|
||||||
|
endpoint.ModelName,
|
||||||
|
endpoint.Provider,
|
||||||
|
endpoint.EndpointUrl,
|
||||||
|
ApiKey = endpoint.ApiKey == "" ? "" : "••••••••",
|
||||||
|
endpoint.CostPer1kPromptTokens,
|
||||||
|
endpoint.CostPer1kCompletionTokens,
|
||||||
|
endpoint.IsActive,
|
||||||
|
endpoint.CreatedAt
|
||||||
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return Ok(endpoints);
|
return Ok(endpoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("endpoints")]
|
[HttpPost("endpoints")]
|
||||||
|
[Authorize(Policy = "AdminOrAbove")]
|
||||||
public async Task<IActionResult> CreateEndpoint([FromBody] CreateModelEndpointDto dto)
|
public async Task<IActionResult> CreateEndpoint([FromBody] CreateModelEndpointDto dto)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(dto.ModelId) || string.IsNullOrWhiteSpace(dto.ModelName))
|
if (string.IsNullOrWhiteSpace(dto.ModelId) || string.IsNullOrWhiteSpace(dto.ModelName)
|
||||||
|
|| string.IsNullOrWhiteSpace(dto.Provider) || !IsValidEndpointUrl(dto.EndpointUrl)
|
||||||
|
|| dto.CostPer1kPromptTokens < 0 || dto.CostPer1kCompletionTokens < 0)
|
||||||
{
|
{
|
||||||
return BadRequest(new { message = "ModelId와 ModelName은 필수 항목입니다." });
|
return BadRequest(new { message = "모델 식별자, 공급자, 유효한 HTTP(S) URL과 0 이상의 비용이 필요합니다." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var existing = await _db.ModelEndpoints.FirstOrDefaultAsync(m => m.ModelId == dto.ModelId);
|
return await ExecuteAdminMutationAsync(
|
||||||
if (existing != null)
|
"model_endpoint.create",
|
||||||
{
|
dto,
|
||||||
return Conflict(new { message = "이미 존재하는 ModelId입니다." });
|
"model_endpoint",
|
||||||
}
|
_ => dto.ModelId.Trim(),
|
||||||
|
dto.Memo,
|
||||||
var endpoint = new ServiceModelEndpoint
|
() => Task.FromResult<object?>(null),
|
||||||
{
|
async () =>
|
||||||
ModelId = dto.ModelId.Trim(),
|
{
|
||||||
ModelName = dto.ModelName.Trim(),
|
if (await _db.ModelEndpoints.AnyAsync(model => model.ModelId == dto.ModelId.Trim()))
|
||||||
Provider = dto.Provider.Trim(),
|
throw new AdminOperationException("model_id_already_exists");
|
||||||
EndpointUrl = dto.EndpointUrl.Trim(),
|
var endpoint = new ServiceModelEndpoint
|
||||||
ApiKey = dto.ApiKey ?? "",
|
{
|
||||||
CostPer1kPromptTokens = dto.CostPer1kPromptTokens,
|
ModelId = dto.ModelId.Trim(),
|
||||||
CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens,
|
ModelName = dto.ModelName.Trim(),
|
||||||
IsActive = true,
|
Provider = dto.Provider.Trim(),
|
||||||
CreatedAt = DateTime.UtcNow
|
EndpointUrl = dto.EndpointUrl.Trim(),
|
||||||
};
|
ApiKey = dto.ApiKey?.Trim() ?? "",
|
||||||
|
CostPer1kPromptTokens = dto.CostPer1kPromptTokens,
|
||||||
_db.ModelEndpoints.Add(endpoint);
|
CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens,
|
||||||
await _db.SaveChangesAsync();
|
IsActive = true,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
return Ok(endpoint);
|
};
|
||||||
|
_db.ModelEndpoints.Add(endpoint);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return ToModelEndpointDto(endpoint);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("endpoints/{id}")]
|
[HttpPut("endpoints/{id}")]
|
||||||
|
[Authorize(Policy = "AdminOrAbove")]
|
||||||
public async Task<IActionResult> UpdateEndpoint(int id, [FromBody] UpdateModelEndpointDto dto)
|
public async Task<IActionResult> UpdateEndpoint(int id, [FromBody] UpdateModelEndpointDto dto)
|
||||||
{
|
{
|
||||||
var endpoint = await _db.ModelEndpoints.FindAsync(id);
|
if (id <= 0 || string.IsNullOrWhiteSpace(dto.ModelName) || string.IsNullOrWhiteSpace(dto.Provider)
|
||||||
if (endpoint == null) return NotFound();
|
|| !IsValidEndpointUrl(dto.EndpointUrl) || dto.CostPer1kPromptTokens < 0 || dto.CostPer1kCompletionTokens < 0)
|
||||||
|
return BadRequest(new { message = "유효한 모델 엔드포인트 값이 필요합니다." });
|
||||||
|
|
||||||
endpoint.ModelName = dto.ModelName.Trim();
|
return await ExecuteAdminMutationAsync(
|
||||||
endpoint.Provider = dto.Provider.Trim();
|
"model_endpoint.update",
|
||||||
endpoint.EndpointUrl = dto.EndpointUrl.Trim();
|
new { id, dto },
|
||||||
endpoint.ApiKey = dto.ApiKey ?? "";
|
"model_endpoint",
|
||||||
endpoint.CostPer1kPromptTokens = dto.CostPer1kPromptTokens;
|
_ => id.ToString(),
|
||||||
endpoint.CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens;
|
dto.Memo,
|
||||||
endpoint.IsActive = dto.IsActive;
|
async () => await _db.ModelEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id)
|
||||||
|
.Select(endpoint => new { endpoint.Id, endpoint.ModelId, endpoint.ModelName, endpoint.Provider, endpoint.EndpointUrl, endpoint.IsActive })
|
||||||
await _db.SaveChangesAsync();
|
.SingleOrDefaultAsync(),
|
||||||
return Ok(endpoint);
|
async () =>
|
||||||
|
{
|
||||||
|
var endpoint = await _db.ModelEndpoints.FindAsync(id)
|
||||||
|
?? throw new AdminOperationException("model_endpoint_not_found");
|
||||||
|
endpoint.ModelName = dto.ModelName.Trim();
|
||||||
|
endpoint.Provider = dto.Provider.Trim();
|
||||||
|
endpoint.EndpointUrl = dto.EndpointUrl.Trim();
|
||||||
|
if (dto.ApiKey != null) endpoint.ApiKey = dto.ApiKey.Trim();
|
||||||
|
endpoint.CostPer1kPromptTokens = dto.CostPer1kPromptTokens;
|
||||||
|
endpoint.CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens;
|
||||||
|
endpoint.IsActive = dto.IsActive;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return ToModelEndpointDto(endpoint);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("endpoints/{id}")]
|
[HttpDelete("endpoints/{id}")]
|
||||||
public async Task<IActionResult> DeleteEndpoint(int id)
|
[Authorize(Policy = "AdminOrAbove")]
|
||||||
|
public async Task<IActionResult> DeleteEndpoint(int id, [FromBody] AdminActionDto dto)
|
||||||
{
|
{
|
||||||
var endpoint = await _db.ModelEndpoints.FindAsync(id);
|
if (id <= 0) return BadRequest();
|
||||||
if (endpoint == null) return NotFound();
|
return await ExecuteAdminMutationAsync(
|
||||||
|
"model_endpoint.delete",
|
||||||
_db.ModelEndpoints.Remove(endpoint);
|
new { id, dto.Memo },
|
||||||
await _db.SaveChangesAsync();
|
"model_endpoint",
|
||||||
|
_ => id.ToString(),
|
||||||
return Ok(new { message = "삭제되었습니다." });
|
dto.Memo,
|
||||||
|
async () => await _db.ModelEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id)
|
||||||
|
.Select(endpoint => new { endpoint.Id, endpoint.ModelId, endpoint.ModelName, endpoint.Provider, endpoint.EndpointUrl, endpoint.IsActive })
|
||||||
|
.SingleOrDefaultAsync(),
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
var endpoint = await _db.ModelEndpoints.FindAsync(id)
|
||||||
|
?? throw new AdminOperationException("model_endpoint_not_found");
|
||||||
|
_db.ModelEndpoints.Remove(endpoint);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return new { message = "삭제되었습니다.", id };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── STT / Transcription Provider Endpoints ────────────────────────────
|
// ── STT / Transcription Provider Endpoints ────────────────────────────
|
||||||
|
|
@ -163,45 +216,88 @@ public class AdminController : ControllerBase
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("stt-endpoints")]
|
[HttpPost("stt-endpoints")]
|
||||||
|
[Authorize(Policy = "AdminOrAbove")]
|
||||||
public async Task<IActionResult> CreateSttEndpoint([FromBody] CreateSttEndpointDto dto)
|
public async Task<IActionResult> CreateSttEndpoint([FromBody] CreateSttEndpointDto dto)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(dto.Name) || string.IsNullOrWhiteSpace(dto.EndpointUrl))
|
if (string.IsNullOrWhiteSpace(dto.Name) || !IsValidEndpointUrl(dto.EndpointUrl)
|
||||||
|
|| dto.CostPerMinute < 0 || dto.CostPerSecond < 0 || dto.FallbackPriority < 1)
|
||||||
{
|
{
|
||||||
return BadRequest(new { message = "이름과 Endpoint URL은 필수입니다." });
|
return BadRequest(new { message = "유효한 STT 이름, HTTP(S) URL, 비용, 우선순위가 필요합니다." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var endpoint = await _sttService.CreateEndpointAsync(dto);
|
return await ExecuteAdminMutationAsync(
|
||||||
return Ok(endpoint);
|
"stt_endpoint.create",
|
||||||
|
dto,
|
||||||
|
"stt_endpoint",
|
||||||
|
result => ((SttProviderEndpointDto)result).Id.ToString(),
|
||||||
|
dto.Memo,
|
||||||
|
() => Task.FromResult<object?>(null),
|
||||||
|
async () => await _sttService.CreateEndpointAsync(dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("stt-endpoints/{id}")]
|
[HttpPut("stt-endpoints/{id}")]
|
||||||
|
[Authorize(Policy = "AdminOrAbove")]
|
||||||
public async Task<IActionResult> UpdateSttEndpoint(int id, [FromBody] UpdateSttEndpointDto dto)
|
public async Task<IActionResult> UpdateSttEndpoint(int id, [FromBody] UpdateSttEndpointDto dto)
|
||||||
{
|
{
|
||||||
try
|
if (id <= 0 || string.IsNullOrWhiteSpace(dto.Name) || !IsValidEndpointUrl(dto.EndpointUrl)
|
||||||
{
|
|| dto.CostPerMinute < 0 || dto.CostPerSecond < 0 || dto.FallbackPriority < 1)
|
||||||
var endpoint = await _sttService.UpdateEndpointAsync(id, dto);
|
return BadRequest(new { message = "유효한 STT 엔드포인트 값이 필요합니다." });
|
||||||
return Ok(endpoint);
|
|
||||||
}
|
return await ExecuteAdminMutationAsync(
|
||||||
catch (KeyNotFoundException)
|
"stt_endpoint.update",
|
||||||
{
|
new { id, dto },
|
||||||
return NotFound(new { message = $"STT Endpoint {id} not found." });
|
"stt_endpoint",
|
||||||
}
|
_ => id.ToString(),
|
||||||
|
dto.Memo,
|
||||||
|
async () => await _db.SttProviderEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id)
|
||||||
|
.Select(endpoint => new { endpoint.Id, endpoint.Name, endpoint.ProviderType, endpoint.EndpointUrl, endpoint.ModelId, endpoint.IsDefault, endpoint.IsActive, endpoint.FallbackPriority })
|
||||||
|
.SingleOrDefaultAsync(),
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
try { return await _sttService.UpdateEndpointAsync(id, dto); }
|
||||||
|
catch (KeyNotFoundException) { throw new AdminOperationException("stt_endpoint_not_found"); }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("stt-endpoints/{id}")]
|
[HttpDelete("stt-endpoints/{id}")]
|
||||||
public async Task<IActionResult> DeleteSttEndpoint(int id)
|
[Authorize(Policy = "AdminOrAbove")]
|
||||||
|
public async Task<IActionResult> DeleteSttEndpoint(int id, [FromBody] AdminActionDto dto)
|
||||||
{
|
{
|
||||||
var deleted = await _sttService.DeleteEndpointAsync(id);
|
if (id <= 0) return BadRequest();
|
||||||
if (!deleted) return NotFound();
|
return await ExecuteAdminMutationAsync(
|
||||||
return Ok(new { message = "STT 엔드포인트가 성공적으로 삭제되었습니다." });
|
"stt_endpoint.delete",
|
||||||
|
new { id, dto.Memo },
|
||||||
|
"stt_endpoint",
|
||||||
|
_ => id.ToString(),
|
||||||
|
dto.Memo,
|
||||||
|
async () => await _db.SttProviderEndpoints.AsNoTracking().Where(endpoint => endpoint.Id == id)
|
||||||
|
.Select(endpoint => new { endpoint.Id, endpoint.Name, endpoint.ProviderType, endpoint.EndpointUrl, endpoint.ModelId, endpoint.IsDefault, endpoint.IsActive, endpoint.FallbackPriority })
|
||||||
|
.SingleOrDefaultAsync(),
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
if (!await _sttService.DeleteEndpointAsync(id)) throw new AdminOperationException("stt_endpoint_not_found");
|
||||||
|
return new { message = "STT 엔드포인트가 성공적으로 삭제되었습니다.", id };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("stt-endpoints/{id}/set-default")]
|
[HttpPost("stt-endpoints/{id}/set-default")]
|
||||||
public async Task<IActionResult> SetDefaultSttEndpoint(int id)
|
[Authorize(Policy = "AdminOrAbove")]
|
||||||
|
public async Task<IActionResult> SetDefaultSttEndpoint(int id, [FromBody] AdminActionDto dto)
|
||||||
{
|
{
|
||||||
var success = await _sttService.SetDefaultEndpointAsync(id);
|
if (id <= 0) return BadRequest();
|
||||||
if (!success) return NotFound();
|
return await ExecuteAdminMutationAsync(
|
||||||
return Ok(new { id, isDefault = true, success = true, message = "기본 클라우드 전사 프로바이더로 설정되었습니다." });
|
"stt_endpoint.set_default",
|
||||||
|
new { id, dto.Memo },
|
||||||
|
"stt_endpoint",
|
||||||
|
_ => id.ToString(),
|
||||||
|
dto.Memo,
|
||||||
|
async () => await _db.SttProviderEndpoints.AsNoTracking().Where(endpoint => endpoint.IsDefault)
|
||||||
|
.Select(endpoint => new { endpoint.Id, endpoint.Name }).ToListAsync(),
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
if (!await _sttService.SetDefaultEndpointAsync(id)) throw new AdminOperationException("stt_endpoint_not_found");
|
||||||
|
return new { id, isDefault = true, success = true, message = "기본 클라우드 전사 프로바이더로 설정되었습니다." };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("stt-endpoints/{id}/test")]
|
[HttpPost("stt-endpoints/{id}/test")]
|
||||||
|
|
@ -212,9 +308,15 @@ public class AdminController : ControllerBase
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("stt-endpoints/test-direct")]
|
[HttpPost("stt-endpoints/test-direct")]
|
||||||
public async Task<IActionResult> TestSttDirect([FromQuery] string endpointUrl, [FromQuery] string? apiKey)
|
[Authorize(Policy = "AdminOrAbove")]
|
||||||
|
public async Task<IActionResult> TestSttDirect([FromBody] DirectSttTestDto dto)
|
||||||
{
|
{
|
||||||
var result = await _sttService.TestEndpointAsync(0, apiKey, endpointUrl);
|
if (!IsValidEndpointUrl(dto.EndpointUrl) || !Uri.TryCreate(dto.EndpointUrl, UriKind.Absolute, out var endpointUri))
|
||||||
|
{
|
||||||
|
return BadRequest(new { message = "유효한 HTTP(S) Endpoint URL이 필요합니다." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _sttService.TestEndpointAsync(0, dto.ApiKey, endpointUri.AbsoluteUri);
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,4 +374,113 @@ public class AdminController : ControllerBase
|
||||||
|
|
||||||
return Ok(report);
|
return Ok(report);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 오프라인 Ed25519 라이선스 발급 감사. admin 콘솔이 서명 후 이 엔드포인트로 기록만 남긴다.
|
||||||
|
[HttpPost("license-audit")]
|
||||||
|
[Authorize(Policy = "SuperAdminOnly")]
|
||||||
|
[RequestSizeLimit(8 * 1024)]
|
||||||
|
public async Task<IActionResult> RecordLicenseAudit([FromBody] LicenseAuditDto dto)
|
||||||
|
{
|
||||||
|
var actorEmail = User.FindFirstValue(ClaimTypes.Email)?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||||
|
if (actorEmail.Length is < 3 or > 150) return Unauthorized(new { error = "invalid_actor" });
|
||||||
|
|
||||||
|
var licenseId = dto.LicenseId?.Trim() ?? string.Empty;
|
||||||
|
var customerEmail = dto.CustomerEmail?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||||
|
var tier = dto.Tier?.Trim() ?? string.Empty;
|
||||||
|
var validity = dto.Validity?.Trim() ?? string.Empty;
|
||||||
|
if (licenseId.Length is < 3 or > 200) return BadRequest(new { error = "invalid_license_id" });
|
||||||
|
if (customerEmail.Length is < 3 or > 150 || !customerEmail.Contains('@')) return BadRequest(new { error = "invalid_customer_email" });
|
||||||
|
if (tier is not ("pro" or "pro_plus" or "team" or "enterprise")) return BadRequest(new { error = "invalid_tier" });
|
||||||
|
if (validity is not ("30d" or "365d" or "lifetime")) return BadRequest(new { error = "invalid_validity" });
|
||||||
|
|
||||||
|
var afterJson = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
licenseId,
|
||||||
|
customerEmail,
|
||||||
|
tier,
|
||||||
|
validity,
|
||||||
|
expiresAt = dto.ExpiresAt
|
||||||
|
});
|
||||||
|
|
||||||
|
_db.AdminAuditEntries.Add(new AdminAuditEntry
|
||||||
|
{
|
||||||
|
ActorEmail = actorEmail,
|
||||||
|
Action = "license.issue",
|
||||||
|
TargetType = "license",
|
||||||
|
TargetId = licenseId,
|
||||||
|
AfterJson = afterJson,
|
||||||
|
Memo = $"Issued {tier} license ({validity}) for {customerEmail}",
|
||||||
|
IdempotencyKey = Guid.NewGuid().ToString("D"),
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Ok(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object ToModelEndpointDto(ServiceModelEndpoint endpoint) => new
|
||||||
|
{
|
||||||
|
endpoint.Id,
|
||||||
|
endpoint.ModelId,
|
||||||
|
endpoint.ModelName,
|
||||||
|
endpoint.Provider,
|
||||||
|
endpoint.EndpointUrl,
|
||||||
|
ApiKey = string.IsNullOrWhiteSpace(endpoint.ApiKey) ? string.Empty : "••••••••",
|
||||||
|
endpoint.CostPer1kPromptTokens,
|
||||||
|
endpoint.CostPer1kCompletionTokens,
|
||||||
|
endpoint.IsActive,
|
||||||
|
endpoint.CreatedAt
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool IsValidEndpointUrl(string? value)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || !string.IsNullOrEmpty(uri.UserInfo)) return false;
|
||||||
|
if (uri.Scheme == Uri.UriSchemeHttp) return uri.IsLoopback;
|
||||||
|
return uri.Scheme == Uri.UriSchemeHttps;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> ExecuteAdminMutationAsync(
|
||||||
|
string operation,
|
||||||
|
object request,
|
||||||
|
string targetType,
|
||||||
|
Func<object, string> targetId,
|
||||||
|
string? memo,
|
||||||
|
Func<Task<object?>> readBefore,
|
||||||
|
Func<Task<object>> mutate)
|
||||||
|
{
|
||||||
|
var actorEmail = User.FindFirstValue(ClaimTypes.Email);
|
||||||
|
if (string.IsNullOrWhiteSpace(actorEmail)) return Unauthorized(new { message = "관리자 이메일 claim이 필요합니다." });
|
||||||
|
|
||||||
|
var idempotencyKey = Request.Headers["Idempotency-Key"].ToString();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _adminOperations.ExecuteAsync(
|
||||||
|
actorEmail,
|
||||||
|
operation,
|
||||||
|
idempotencyKey,
|
||||||
|
request,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
memo ?? string.Empty,
|
||||||
|
readBefore,
|
||||||
|
mutate);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (AdminOperationException ex) when (ex.Message == "idempotency_key_reused_with_different_request")
|
||||||
|
{
|
||||||
|
return Conflict(new { message = ex.Message });
|
||||||
|
}
|
||||||
|
catch (AdminOperationException ex) when (ex.Message.EndsWith("_not_found", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return NotFound(new { message = ex.Message });
|
||||||
|
}
|
||||||
|
catch (AdminOperationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { message = ex.Message });
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
return Conflict(new { message = "admin_operation_conflict" });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,14 @@ public record LoginDto(string Email, string Password);
|
||||||
public record AuthResponseDto(string Token, string Email, string Role, DateTime ExpiresAt);
|
public record AuthResponseDto(string Token, string Email, string Role, DateTime ExpiresAt);
|
||||||
public record UserInfoDto(int Id, string Email, string Role, DateTime CreatedAt, DateTime? LastLoginAt, bool IsActive);
|
public record UserInfoDto(int Id, string Email, string Role, DateTime CreatedAt, DateTime? LastLoginAt, bool IsActive);
|
||||||
|
|
||||||
|
// License issuance audit trail (offline Ed25519 license keys issued from the admin console)
|
||||||
|
public record LicenseAuditDto(
|
||||||
|
string LicenseId,
|
||||||
|
string CustomerEmail,
|
||||||
|
string Tier,
|
||||||
|
string Validity,
|
||||||
|
long? ExpiresAt);
|
||||||
|
|
||||||
// LLM DTOs
|
// LLM DTOs
|
||||||
public record LlmGenerateRequest(string Prompt, string? Model = null, string? SystemPrompt = null, double Temperature = 0.7, int MaxTokens = 2048);
|
public record LlmGenerateRequest(string Prompt, string? Model = null, string? SystemPrompt = null, double Temperature = 0.7, int MaxTokens = 2048);
|
||||||
public record LlmGenerateResponse(string Text, string Model, int PromptTokens, int CompletionTokens, double TotalDurationMs, decimal Cost);
|
public record LlmGenerateResponse(string Text, string Model, int PromptTokens, int CompletionTokens, double TotalDurationMs, decimal Cost);
|
||||||
|
|
@ -23,7 +31,8 @@ public record CreateModelEndpointDto(
|
||||||
string EndpointUrl,
|
string EndpointUrl,
|
||||||
string ApiKey,
|
string ApiKey,
|
||||||
decimal CostPer1kPromptTokens,
|
decimal CostPer1kPromptTokens,
|
||||||
decimal CostPer1kCompletionTokens
|
decimal CostPer1kCompletionTokens,
|
||||||
|
string? Memo = null
|
||||||
);
|
);
|
||||||
|
|
||||||
public record UpdateModelEndpointDto(
|
public record UpdateModelEndpointDto(
|
||||||
|
|
@ -33,7 +42,8 @@ public record UpdateModelEndpointDto(
|
||||||
string ApiKey,
|
string ApiKey,
|
||||||
decimal CostPer1kPromptTokens,
|
decimal CostPer1kPromptTokens,
|
||||||
decimal CostPer1kCompletionTokens,
|
decimal CostPer1kCompletionTokens,
|
||||||
bool IsActive
|
bool IsActive,
|
||||||
|
string? Memo = null
|
||||||
);
|
);
|
||||||
|
|
||||||
public record ServerStatsDto(
|
public record ServerStatsDto(
|
||||||
|
|
@ -117,7 +127,8 @@ public record CreateSttEndpointDto(
|
||||||
bool IsDefault,
|
bool IsDefault,
|
||||||
bool IsActive,
|
bool IsActive,
|
||||||
int FallbackPriority,
|
int FallbackPriority,
|
||||||
string? ExtraHeadersJson
|
string? ExtraHeadersJson,
|
||||||
|
string? Memo = null
|
||||||
);
|
);
|
||||||
|
|
||||||
public record UpdateSttEndpointDto(
|
public record UpdateSttEndpointDto(
|
||||||
|
|
@ -135,9 +146,12 @@ public record UpdateSttEndpointDto(
|
||||||
bool IsDefault,
|
bool IsDefault,
|
||||||
bool IsActive,
|
bool IsActive,
|
||||||
int FallbackPriority,
|
int FallbackPriority,
|
||||||
string? ExtraHeadersJson
|
string? ExtraHeadersJson,
|
||||||
|
string? Memo = null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public record AdminActionDto(string? Memo);
|
||||||
|
|
||||||
public record SttTestResultDto(
|
public record SttTestResultDto(
|
||||||
bool Success,
|
bool Success,
|
||||||
string Message,
|
string Message,
|
||||||
|
|
@ -147,6 +161,8 @@ public record SttTestResultDto(
|
||||||
string? ModelId
|
string? ModelId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public record DirectSttTestDto(string EndpointUrl, string? ApiKey);
|
||||||
|
|
||||||
public record SttUsageReportDto(
|
public record SttUsageReportDto(
|
||||||
int TotalTranscriptions,
|
int TotalTranscriptions,
|
||||||
double TotalAudioMinutes,
|
double TotalAudioMinutes,
|
||||||
|
|
@ -172,4 +188,3 @@ public record SttUserUsageSummaryDto(
|
||||||
double TotalAudioMinutes,
|
double TotalAudioMinutes,
|
||||||
decimal TotalCost
|
decimal TotalCost
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ using System.Text;
|
||||||
using D3ROVoice.Api.Data;
|
using D3ROVoice.Api.Data;
|
||||||
using D3ROVoice.Api.Services;
|
using D3ROVoice.Api.Services;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
var serverStartTime = DateTime.UtcNow;
|
var serverStartTime = DateTime.UtcNow;
|
||||||
|
|
@ -13,6 +15,19 @@ var serverStartTime = DateTime.UtcNow;
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
builder.Services.AddHttpClient();
|
builder.Services.AddHttpClient();
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddRateLimiter(options =>
|
||||||
|
{
|
||||||
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
_ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 10,
|
||||||
|
Window = TimeSpan.FromMinutes(1),
|
||||||
|
QueueLimit = 0,
|
||||||
|
AutoReplenishment = true
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services.AddSwaggerGen(c =>
|
builder.Services.AddSwaggerGen(c =>
|
||||||
{
|
{
|
||||||
|
|
@ -45,11 +60,24 @@ builder.Services.AddDbContext<AppDbContext>(options =>
|
||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
builder.Services.AddScoped<ILlmProxyService, LlmProxyService>();
|
builder.Services.AddScoped<ILlmProxyService, LlmProxyService>();
|
||||||
builder.Services.AddScoped<ISttProxyService, SttProxyService>();
|
builder.Services.AddScoped<ISttProxyService, SttProxyService>();
|
||||||
|
builder.Services.AddScoped<IAdminOperationService, AdminOperationService>();
|
||||||
|
|
||||||
// JWT Authentication Configuration
|
// JWT Authentication Configuration
|
||||||
var secretKey = builder.Configuration["Jwt:SecretKey"]
|
var secretKey = builder.Configuration["JWT_SECRET"];
|
||||||
?? builder.Configuration["JWT_SECRET"]
|
if (string.IsNullOrWhiteSpace(secretKey) || Encoding.UTF8.GetByteCount(secretKey) < 32)
|
||||||
?? "D3ROVoice_Super_Secure_Secret_Key_2026_Key!";
|
{
|
||||||
|
throw new InvalidOperationException("JWT_SECRET must contain at least 32 non-whitespace bytes.");
|
||||||
|
}
|
||||||
|
var jwtIssuer = builder.Configuration["JWT_ISSUER"];
|
||||||
|
if (string.IsNullOrWhiteSpace(jwtIssuer))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("JWT_ISSUER is required.");
|
||||||
|
}
|
||||||
|
var jwtAudience = builder.Configuration["JWT_AUDIENCE"];
|
||||||
|
if (string.IsNullOrWhiteSpace(jwtAudience))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("JWT_AUDIENCE is required.");
|
||||||
|
}
|
||||||
var keyBytes = Encoding.UTF8.GetBytes(secretKey);
|
var keyBytes = Encoding.UTF8.GetBytes(secretKey);
|
||||||
|
|
||||||
builder.Services.AddAuthentication(options =>
|
builder.Services.AddAuthentication(options =>
|
||||||
|
|
@ -59,25 +87,103 @@ builder.Services.AddAuthentication(options =>
|
||||||
})
|
})
|
||||||
.AddJwtBearer(options =>
|
.AddJwtBearer(options =>
|
||||||
{
|
{
|
||||||
options.RequireHttpsMetadata = false;
|
options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
|
||||||
options.SaveToken = true;
|
options.SaveToken = true;
|
||||||
options.TokenValidationParameters = new TokenValidationParameters
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
{
|
{
|
||||||
ValidateIssuerSigningKey = true,
|
ValidateIssuerSigningKey = true,
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
|
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
|
||||||
ValidateIssuer = false,
|
ValidateIssuer = true,
|
||||||
ValidateAudience = false,
|
ValidIssuer = jwtIssuer,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidAudience = jwtAudience,
|
||||||
|
RequireExpirationTime = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
ClockSkew = TimeSpan.Zero
|
ClockSkew = TimeSpan.Zero
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static string NormalizeAdminRole(string value) =>
|
||||||
|
value.Replace("_", string.Empty, StringComparison.Ordinal)
|
||||||
|
.Replace("-", string.Empty, StringComparison.Ordinal)
|
||||||
|
.Trim()
|
||||||
|
.ToLowerInvariant();
|
||||||
|
|
||||||
|
builder.Services.AddAuthorization(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy("ManagerOrAbove", policy =>
|
||||||
|
policy.RequireAuthenticatedUser().RequireAssertion(context =>
|
||||||
|
context.User.Claims
|
||||||
|
.Where(claim => claim.Type == System.Security.Claims.ClaimTypes.Role || claim.Type == "role")
|
||||||
|
.Select(claim => NormalizeAdminRole(claim.Value))
|
||||||
|
.Any(role => role is "manager" or "admin" or "superadmin")));
|
||||||
|
options.AddPolicy("AdminOrAbove", policy =>
|
||||||
|
policy.RequireAuthenticatedUser().RequireAssertion(context =>
|
||||||
|
context.User.Claims
|
||||||
|
.Where(claim => claim.Type == System.Security.Claims.ClaimTypes.Role || claim.Type == "role")
|
||||||
|
.Select(claim => NormalizeAdminRole(claim.Value))
|
||||||
|
.Any(role => role is "admin" or "superadmin")));
|
||||||
|
options.AddPolicy("SuperAdminOnly", policy =>
|
||||||
|
policy.RequireAuthenticatedUser().RequireAssertion(context =>
|
||||||
|
context.User.Claims
|
||||||
|
.Where(claim => claim.Type == System.Security.Claims.ClaimTypes.Role || claim.Type == "role")
|
||||||
|
.Select(claim => NormalizeAdminRole(claim.Value))
|
||||||
|
.Any(role => role is "superadmin")));
|
||||||
|
});
|
||||||
|
|
||||||
|
var corsOriginsRaw = builder.Configuration["Cors:AllowedOrigins"]
|
||||||
|
?? builder.Configuration["CORS_ALLOWED_ORIGINS"];
|
||||||
|
if (string.IsNullOrWhiteSpace(corsOriginsRaw))
|
||||||
|
{
|
||||||
|
if (!builder.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("CORS_ALLOWED_ORIGINS is required outside Development.");
|
||||||
|
}
|
||||||
|
|
||||||
|
corsOriginsRaw = "http://localhost:3000,http://localhost:3001,http://localhost:5173";
|
||||||
|
}
|
||||||
|
|
||||||
|
var corsOrigins = corsOriginsRaw
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select(origin => origin.TrimEnd('/'))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
if (corsOrigins.Length == 0 || corsOrigins.Any(origin =>
|
||||||
|
!Uri.TryCreate(origin, UriKind.Absolute, out var uri)
|
||||||
|
|| (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
|
||||||
|
|| !string.IsNullOrEmpty(uri.UserInfo)
|
||||||
|
|| !string.IsNullOrEmpty(uri.Query)
|
||||||
|
|| !string.IsNullOrEmpty(uri.Fragment)
|
||||||
|
|| uri.AbsolutePath != "/"))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("CORS_ALLOWED_ORIGINS must be a comma-separated list of HTTP(S) origins without paths or wildcards.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var allowedHosts = builder.Configuration["ALLOWED_HOSTS"];
|
||||||
|
if (string.IsNullOrWhiteSpace(allowedHosts))
|
||||||
|
{
|
||||||
|
if (!builder.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("ALLOWED_HOSTS is required outside Development.");
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedHosts = "localhost;127.0.0.1";
|
||||||
|
}
|
||||||
|
if (allowedHosts.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Any(host => host == "*" || host.Contains('/') || host.Contains('\\')))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("ALLOWED_HOSTS must contain explicit semicolon-separated host names without wildcards or paths.");
|
||||||
|
}
|
||||||
|
builder.Configuration["AllowedHosts"] = allowedHosts;
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
options.AddPolicy("AllowAll", policy =>
|
options.AddPolicy("ConfiguredOrigins", policy =>
|
||||||
{
|
{
|
||||||
policy.AllowAnyOrigin()
|
policy.WithOrigins(corsOrigins)
|
||||||
.AllowAnyMethod()
|
.AllowAnyMethod()
|
||||||
.AllowAnyHeader();
|
.AllowAnyHeader()
|
||||||
|
.SetPreflightMaxAge(TimeSpan.FromHours(1));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -88,6 +194,75 @@ using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
db.Database.EnsureCreated();
|
db.Database.EnsureCreated();
|
||||||
|
db.Database.ExecuteSqlRaw("""
|
||||||
|
CREATE TABLE IF NOT EXISTS "AdminOperationRequests" (
|
||||||
|
"Id" INTEGER NOT NULL CONSTRAINT "PK_AdminOperationRequests" PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"ActorEmail" TEXT NOT NULL,
|
||||||
|
"IdempotencyKey" TEXT NOT NULL,
|
||||||
|
"Operation" TEXT NOT NULL,
|
||||||
|
"RequestHash" TEXT NOT NULL,
|
||||||
|
"ResponseJson" TEXT NOT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "IX_AdminOperationRequests_ActorEmail_IdempotencyKey"
|
||||||
|
ON "AdminOperationRequests" ("ActorEmail", "IdempotencyKey");
|
||||||
|
CREATE TABLE IF NOT EXISTS "AdminAuditEntries" (
|
||||||
|
"Id" INTEGER NOT NULL CONSTRAINT "PK_AdminAuditEntries" PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"ActorEmail" TEXT NOT NULL,
|
||||||
|
"Action" TEXT NOT NULL,
|
||||||
|
"TargetType" TEXT NOT NULL,
|
||||||
|
"TargetId" TEXT NOT NULL,
|
||||||
|
"BeforeJson" TEXT NULL,
|
||||||
|
"AfterJson" TEXT NULL,
|
||||||
|
"Memo" TEXT NOT NULL,
|
||||||
|
"IdempotencyKey" TEXT NOT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_AdminAuditEntries_CreatedAt"
|
||||||
|
ON "AdminAuditEntries" ("CreatedAt");
|
||||||
|
""");
|
||||||
|
|
||||||
|
// The former repository-wide SHA-256 password scheme and seeded admin
|
||||||
|
// credentials are compromised by design. Disable those rows so they can
|
||||||
|
// never authenticate; an operator provisions the administrator either via
|
||||||
|
// the one-time bootstrap token or the ADMIN_EMAIL/ADMIN_PASSWORD env pair
|
||||||
|
// below (auto-created only when no active administrator exists).
|
||||||
|
var legacyPasswordUsers = db.Users
|
||||||
|
.Where(user => !user.PasswordHash.StartsWith("AQAAAA"))
|
||||||
|
.ToList();
|
||||||
|
if (legacyPasswordUsers.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var legacyUser in legacyPasswordUsers)
|
||||||
|
{
|
||||||
|
legacyUser.IsActive = false;
|
||||||
|
legacyUser.Role = "LegacyDisabled";
|
||||||
|
}
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
// .env 기반 관리자 프로비저닝: 활성 관리자가 없을 때만 ADMIN_EMAIL/ADMIN_PASSWORD
|
||||||
|
// 로 SuperAdmin을 자동 생성한다(멱등 — 이미 있으면 건드리지 않는다).
|
||||||
|
var envAdminEmail = builder.Configuration["ADMIN_EMAIL"]?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||||
|
var envAdminPassword = builder.Configuration["ADMIN_PASSWORD"] ?? string.Empty;
|
||||||
|
if (
|
||||||
|
envAdminEmail.Length >= 3
|
||||||
|
&& envAdminEmail.Contains('@')
|
||||||
|
&& envAdminPassword.Length >= 8
|
||||||
|
&& !db.Users.Any(u => u.IsActive)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var envAdmin = new User
|
||||||
|
{
|
||||||
|
Email = envAdminEmail,
|
||||||
|
Role = "SuperAdmin",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
IsActive = true,
|
||||||
|
};
|
||||||
|
envAdmin.PasswordHash = new PasswordHasher<User>().HashPassword(envAdmin, envAdminPassword);
|
||||||
|
db.Users.Add(envAdmin);
|
||||||
|
db.SaveChanges();
|
||||||
|
Console.WriteLine($"Provisioned SuperAdmin from ADMIN_EMAIL env: {envAdminEmail}");
|
||||||
|
}
|
||||||
|
|
||||||
// Default Model Endpoints if empty
|
// Default Model Endpoints if empty
|
||||||
if (!db.ModelEndpoints.Any())
|
if (!db.ModelEndpoints.Any())
|
||||||
|
|
@ -205,47 +380,78 @@ using (var scope = app.Services.CreateScope())
|
||||||
db.SaveChanges();
|
db.SaveChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default Admin User seed & update password to Test1234!
|
// Administrative accounts are never seeded with repository credentials.
|
||||||
var adminUser = db.Users.FirstOrDefault(u => u.Email == "admin" || u.Email == "admin@d3ro.voice");
|
// Provisioning is performed through the authenticated one-time bootstrap flow.
|
||||||
var passwordHash = AuthService.HashPassword("Test1234!");
|
|
||||||
if (adminUser == null)
|
|
||||||
{
|
|
||||||
db.Users.AddRange(
|
|
||||||
new User
|
|
||||||
{
|
|
||||||
Email = "admin",
|
|
||||||
PasswordHash = passwordHash,
|
|
||||||
Role = "SuperAdmin",
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
IsActive = true
|
|
||||||
},
|
|
||||||
new User
|
|
||||||
{
|
|
||||||
Email = "admin@d3ro.voice",
|
|
||||||
PasswordHash = passwordHash,
|
|
||||||
Role = "SuperAdmin",
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
IsActive = true
|
|
||||||
}
|
|
||||||
);
|
|
||||||
db.SaveChanges();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
adminUser.PasswordHash = passwordHash;
|
|
||||||
adminUser.Role = "SuperAdmin";
|
|
||||||
adminUser.IsActive = true;
|
|
||||||
db.SaveChanges();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseSwagger();
|
if (app.Environment.IsDevelopment())
|
||||||
app.UseSwaggerUI();
|
{
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
}
|
||||||
|
|
||||||
app.UseCors("AllowAll");
|
app.UseCors("ConfiguredOrigins");
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
var isInvitePage =
|
||||||
|
context.Request.Path.StartsWithSegments("/accept-invite")
|
||||||
|
|| context.Request.Path.Equals("/accept-invite.html");
|
||||||
|
|
||||||
|
if (isInvitePage)
|
||||||
|
{
|
||||||
|
context.Response.OnStarting(() =>
|
||||||
|
{
|
||||||
|
// The invite token lives in the query string. Do not cache the page and
|
||||||
|
// prevent CDN HTML transforms (including analytics script injection).
|
||||||
|
context.Response.Headers["Cache-Control"] = "no-store, no-transform";
|
||||||
|
context.Response.Headers["Content-Security-Policy"] =
|
||||||
|
"default-src 'self'; base-uri 'none'; connect-src 'none'; font-src 'self'; " +
|
||||||
|
"form-action 'none'; frame-ancestors 'none'; img-src 'self' data:; " +
|
||||||
|
"object-src 'none'; script-src 'self'; style-src 'self'";
|
||||||
|
context.Response.Headers["Permissions-Policy"] =
|
||||||
|
"camera=(), microphone=(), geolocation=(), payment=(), usb=()";
|
||||||
|
context.Response.Headers["Referrer-Policy"] = "no-referrer";
|
||||||
|
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||||
|
context.Response.Headers["X-Frame-Options"] = "DENY";
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.Request.Path.Equals("/accept-invite"))
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status308PermanentRedirect;
|
||||||
|
context.Response.Headers.Location = $"/accept-invite/{context.Request.QueryString}";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
app.UseDefaultFiles();
|
app.UseDefaultFiles();
|
||||||
|
// Historical mobile binaries remain in the checkout for forensics only. They
|
||||||
|
// are not official releases and must never be reachable through StaticFiles.
|
||||||
|
app.Use(async (context, next) =>
|
||||||
|
{
|
||||||
|
var requestPath = context.Request.Path.Value ?? string.Empty;
|
||||||
|
var fileName = Path.GetFileName(requestPath);
|
||||||
|
var legacyMarketingAsset = requestPath.Equals("/assets/index-D7M5UQvT.js", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| requestPath.Equals("/assets/index-JlYFxlAJ.js", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var mobileReleasePath = requestPath.StartsWith("/releases/", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& (fileName.EndsWith(".apk", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| fileName.EndsWith(".aab", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| (fileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& (fileName.Contains("android", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| fileName.Contains("signed", StringComparison.OrdinalIgnoreCase))));
|
||||||
|
if (mobileReleasePath || legacyMarketingAsset)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
|
|
||||||
|
app.UseRateLimiter();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
|
@ -272,7 +478,10 @@ app.MapGet("/api/health", () => Results.Ok(new
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
|
app.MapFallbackToFile("/accept-invite", "accept-invite.html");
|
||||||
// Fallback to Admin BackOffice UI index.html
|
// Fallback to Admin BackOffice UI index.html
|
||||||
app.MapFallbackToFile("/admin/{*path}", "admin/index.html");
|
app.MapFallbackToFile("/admin/{*path}", "admin/index.html");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
public partial class Program { }
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@
|
||||||
# D3RO Voice — Standalone NAS Docker Compose Specification
|
# D3RO Voice — Standalone NAS Docker Compose Specification
|
||||||
# Includes: Core API + Promotional Site + Full Next.js Admin CRM
|
# Includes: Core API + Promotional Site + Full Next.js Admin CRM
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
d3ro-api-server:
|
d3ro-api-server:
|
||||||
image: d3ro-voice-api:latest
|
image: d3ro-voice-api:latest
|
||||||
|
|
@ -15,7 +13,13 @@ services:
|
||||||
- ASPNETCORE_ENVIRONMENT=Production
|
- ASPNETCORE_ENVIRONMENT=Production
|
||||||
- ASPNETCORE_URLS=http://+:5000
|
- ASPNETCORE_URLS=http://+:5000
|
||||||
- DATA_DIR=/app/data
|
- DATA_DIR=/app/data
|
||||||
- JWT_SECRET=${JWT_SECRET:-D3ROVoice_Super_Secure_Secret_Key_2026_Key!}
|
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||||
|
- JWT_ISSUER=${JWT_ISSUER:?JWT_ISSUER is required}
|
||||||
|
- JWT_AUDIENCE=${JWT_AUDIENCE:?JWT_AUDIENCE is required}
|
||||||
|
- ADMIN_BOOTSTRAP_TOKEN=${ADMIN_BOOTSTRAP_TOKEN:?ADMIN_BOOTSTRAP_TOKEN is required}
|
||||||
|
- D3RO_API_TOKEN=${D3RO_API_TOKEN:?D3RO_API_TOKEN is required}
|
||||||
|
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:?CORS_ALLOWED_ORIGINS is required}
|
||||||
|
- ALLOWED_HOSTS=${ALLOWED_HOSTS:?ALLOWED_HOSTS is required}
|
||||||
- TZ=${TZ:-Asia/Seoul}
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
volumes:
|
volumes:
|
||||||
- ${DATA_PATH:-./data}:/app/data
|
- ${DATA_PATH:-./data}:/app/data
|
||||||
|
|
@ -35,6 +39,11 @@ services:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3001
|
- PORT=3001
|
||||||
- NEXT_PUBLIC_API_URL=${PUBLIC_URL:-https://d3ro.chanpaca.net}
|
- NEXT_PUBLIC_API_URL=${PUBLIC_URL:-https://d3ro.chanpaca.net}
|
||||||
|
- API_SERVER_URL=${API_SERVER_URL:?API_SERVER_URL is required}
|
||||||
|
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET:?ADMIN_SESSION_SECRET is required}
|
||||||
|
- ADMIN_COOKIE_SECURE=${ADMIN_COOKIE_SECURE:-true}
|
||||||
|
- SUPABASE_URL=${SUPABASE_URL:?SUPABASE_URL is required}
|
||||||
|
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY:?SUPABASE_SERVICE_ROLE_KEY is required}
|
||||||
depends_on:
|
depends_on:
|
||||||
- d3ro-api-server
|
- d3ro-api-server
|
||||||
logging:
|
logging:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# D3RO Voice — Core Cloud API Server & Official Promotion Landing Site
|
# D3RO Voice — Core Cloud API Server & Official Promotion Landing Site
|
||||||
|
|
@ -17,7 +15,13 @@ services:
|
||||||
- ASPNETCORE_ENVIRONMENT=Production
|
- ASPNETCORE_ENVIRONMENT=Production
|
||||||
- ASPNETCORE_URLS=http://+:5000
|
- ASPNETCORE_URLS=http://+:5000
|
||||||
- DATA_DIR=/app/data
|
- DATA_DIR=/app/data
|
||||||
- JWT_SECRET=${JWT_SECRET:-D3ROVoice_Super_Secure_Secret_Key_2026_Key!}
|
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||||
|
- JWT_ISSUER=${JWT_ISSUER:?JWT_ISSUER is required}
|
||||||
|
- JWT_AUDIENCE=${JWT_AUDIENCE:?JWT_AUDIENCE is required}
|
||||||
|
- ADMIN_BOOTSTRAP_TOKEN=${ADMIN_BOOTSTRAP_TOKEN:?ADMIN_BOOTSTRAP_TOKEN is required}
|
||||||
|
- D3RO_API_TOKEN=${D3RO_API_TOKEN:?D3RO_API_TOKEN is required}
|
||||||
|
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:?CORS_ALLOWED_ORIGINS is required}
|
||||||
|
- ALLOWED_HOSTS=${ALLOWED_HOSTS:?ALLOWED_HOSTS is required}
|
||||||
- TZ=${TZ:-Asia/Seoul}
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
volumes:
|
volumes:
|
||||||
- ${DATA_PATH:-./data}:/app/data
|
- ${DATA_PATH:-./data}:/app/data
|
||||||
|
|
@ -43,6 +47,9 @@ services:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3001
|
- PORT=3001
|
||||||
- NEXT_PUBLIC_API_URL=${PUBLIC_URL:-https://d3ro.chanpaca.net}
|
- NEXT_PUBLIC_API_URL=${PUBLIC_URL:-https://d3ro.chanpaca.net}
|
||||||
|
- API_SERVER_URL=${API_SERVER_URL:?API_SERVER_URL is required}
|
||||||
|
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET:?ADMIN_SESSION_SECRET is required}
|
||||||
|
- ADMIN_COOKIE_SECURE=${ADMIN_COOKIE_SECURE:-true}
|
||||||
depends_on:
|
depends_on:
|
||||||
- d3ro-api-server
|
- d3ro-api-server
|
||||||
logging:
|
logging:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue