feat(admin): 예전/최신 어드민 통합 — 실데이터 복원 + 인증 아키텍처 정리

예전 배포본(HEAD)의 상세 기능을 새 아키텍처(인증=.NET 백엔드, 데이터=Supabase)
위에 실데이터로 복원. 예전 HEAD는 서명 쿠키를 거부하고 위조 쿠키는 통과시키는
인증 결함이 있었고, 삭제된 페이지 다수는 백엔드 호출 0인 하드코딩 목업이었음.

인증/세션
- 로그인 이메일 전용화(username 폐지), 에러 키별 안내 메시지
- ADMIN_COOKIE_SECURE 옵션: TLS 없는 LAN HTTP 배포에서 Secure 쿠키 유실로
  로그인이 유지되지 않던 문제 해결 (login/logout route, admin-session, compose, .env.example)
- Supabase 미설정 시 우아한 저하: isSupabaseAdminConfigured + UnavailableAdminPanel

기능 복원 (실데이터)
- Release Hub: Forgejo API 실데이터(다운로드 수/SHA-256 체크섬/릴리스 이력)
- Ad Monetization: 데스크톱 미디에이션 10개 어댑터 로스터(fail-closed) + ad_reward_claims 통계
- License Issuer: 서버사이드 Ed25519 서명(/api/admin/license, super_admin 전용),
  개인키는 ADMIN_LICENSE_PRIVATE_KEY env로만, 발급 감사를 .NET AdminAuditEntries에 기록
- Service Models: STT 7종/LLM 5종 프리셋 드롭다운 + 자동채움
- 대시보드 ARR/MRR KPI: Supabase 구독 실집계(티어 월단가 기반)
- 사용자 상세 티어별 기능 배지(pro_plus 조건부)

.NET
- SuperAdminOnly 정책 추가, /api/admin/license-audit 엔드포인트, LicenseAuditDto
This commit is contained in:
Yun Chan 2026-08-23 23:38:08 +09:00
parent a9c9a1ca6e
commit 5a34f66981
66 changed files with 4471 additions and 3501 deletions

72
.env.example Normal file
View 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=

View file

@ -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"]

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

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

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

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

View file

@ -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"

View file

@ -1,242 +1,29 @@
// 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 (
<>
{/* Header */}
<Box <Box
sx={{ sx={{
...panelSx, ...panelSx,
@ -247,316 +34,247 @@ export default async function AdminAdsPage(): Promise<React.ReactElement> {
flexWrap: 'wrap', flexWrap: 'wrap',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
gap: 2, gap: 2
}}
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography
variant="h5"
sx={{
fontFamily: FONT_SANS,
fontWeight: 800,
fontSize: { xs: '20px', md: '24px' },
color: C.bright,
letterSpacing: '-0.02em',
}}
>
Multi-Ad Mediation & Revenue Settlement Hub
</Typography>
<TactileBadge mono tone="accent">10 NETWORKS ACTIVE</TactileBadge>
<TactileBadge mono tone="success">AUCTION HEALTHY</TactileBadge>
</Box>
<Typography
sx={{
fontFamily: FONT_MONO,
fontSize: '12px',
color: C.dim,
mt: 0.5,
}}
>
Real-time header bidding mediation, floor eCPM management, and automated tax withholding settlement ledger.
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center' }}>
<Button
variant="outlined"
size="small"
sx={{
fontFamily: FONT_MONO,
fontSize: '12px',
color: C.cyanLight,
borderColor: 'rgba(56, 189, 248, 0.3)',
textTransform: 'none',
'&:hover': { borderColor: C.cyanLight, bgcolor: 'rgba(56, 189, 248, 0.08)' },
}}
>
📥 Export CSV Settlement
</Button>
<Button
variant="contained"
size="small"
sx={{
...primaryButtonSx,
fontFamily: FONT_MONO,
fontSize: '12px',
textTransform: 'none',
}}
>
+ Add Demand Partner
</Button>
</Box>
</Box>
{/* Publisher Account Banner */}
<Box
sx={{
...panelSx,
p: 2.5,
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
bgcolor: 'rgba(14, 165, 233, 0.05)',
border: '1px solid rgba(56, 189, 248, 0.2)',
}} }}
> >
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box <Box
sx={{ sx={{
width: 40, width: 4,
height: 40, height: 32,
borderRadius: '10px', borderRadius: '999px',
bgcolor: 'rgba(56, 189, 248, 0.15)', background: `linear-gradient(180deg, ${C.orange} 0%, ${C.accent} 60%, ${C.purple} 100%)`
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: C.cyanLight,
fontWeight: 800,
fontSize: '18px',
}} }}
> />
P
</Box>
<Box> <Box>
<Typography sx={{ fontFamily: FONT_SANS, fontWeight: 700, fontSize: '14px', color: C.bright }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
Registered Publisher Account: yunchanpaca@gmail.com <Typography
</Typography> component="h1"
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}> sx={{ fontFamily: FONT_SANS, fontSize: '20px', fontWeight: 500, color: C.bright, letterSpacing: '-0.02em', m: 0 }}
Payout Beneficiary: D3RO Voice AI KB국민은행 928702-00-184920 120-88-01923
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<TactileBadge mono tone="success">KYC Verified</TactileBadge>
<TactileBadge mono tone="accent">3.3% </TactileBadge>
</Box>
</Box>
{/* KPI Cards */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' },
gap: 2,
}}
> >
<DoubleBezelCard interactive> Ad Mediation &amp; Monetization
<Box sx={{ p: 2.5 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
Est. Monthly Ad Revenue
</Typography> </Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.cyanLight, my: 0.5 }}> <TactileBadge tone="warning" mono>
${totalGrossRevenue.toFixed(2)} FAIL-CLOSED SANDBOX
</Typography> </TactileBadge>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.green400 }}> </Box>
{(totalGrossRevenue * 1350).toLocaleString()} ( 1,350) <Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, letterSpacing: '0.04em', mt: 0.25 }}>
Mediation waterfall roster, rewarded token grants, network integration state
</Typography> </Typography>
</Box> </Box>
</DoubleBezelCard>
<DoubleBezelCard interactive>
<Box sx={{ p: 2.5 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
Weighted Avg. eCPM
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.bright, my: 0.5 }}>
${avgEcpm}
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight }}>
Floor: $2.00 min Max: $18.00
</Typography>
</Box>
</DoubleBezelCard>
<DoubleBezelCard interactive>
<Box sx={{ p: 2.5 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
Total Ad Impressions
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.bright, my: 0.5 }}>
{(totalImpressions / 1000).toFixed(1)}k
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
Avg. Fill Rate: 96.8%
</Typography>
</Box>
</DoubleBezelCard>
<DoubleBezelCard interactive>
<Box sx={{ p: 2.5 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
Net Payout Settled (KRW)
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.green400, my: 0.5 }}>
{(totalSettledKrw / 10000).toFixed(1)}
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.green400 }}>
{totalSettledKrw.toLocaleString()}
</Typography>
</Box>
</DoubleBezelCard>
</Box>
{/* 10+ Multi-Ad Network Mediation Matrix */}
<Box sx={{ ...panelSx, p: { xs: 2, md: 3 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
10+ Active Ad Networks & Header Bidding Matrix
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
First-price real-time bidding auction with sub-800ms SLA fallback to Direct House AI Sponsors.
</Typography>
</Box>
<TactileBadge mono tone="default">AUCTION TIMEOUT: 800MS</TactileBadge>
</Box>
<Box sx={{ overflowX: 'auto' }}>
<Box component="table" sx={tableSx}>
<thead>
<tr>
<th style={{ textAlign: 'left' }}>Demand Partner</th>
<th style={{ textAlign: 'left' }}>Adapter Protocol</th>
<th style={{ textAlign: 'left' }}>Primary Slot Format</th>
<th style={{ textAlign: 'right' }}>Impressions</th>
<th style={{ textAlign: 'right' }}>CTR</th>
<th style={{ textAlign: 'right' }}>Bid eCPM</th>
<th style={{ textAlign: 'right' }}>Gross Revenue</th>
<th style={{ textAlign: 'center' }}>Fill Rate</th>
<th style={{ textAlign: 'center' }}>Status</th>
</tr>
</thead>
<tbody>
{networks.map((net) => (
<tr key={net.id}>
<td style={{ fontWeight: 600, color: C.bright }}>{net.name}</td>
<td style={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight }}>{net.adapterType}</td>
<td style={{ color: C.dim, fontSize: '12px' }}>{net.format}</td>
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, color: C.bright }}>
{net.impressions.toLocaleString()}
</td>
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, color: C.green400 }}>{net.ctr}</td>
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, fontWeight: 700, color: C.cyanLight }}>
${net.ecpm.toFixed(2)}
</td>
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, fontWeight: 700, color: C.bright }}>
${net.grossRevenueUsd.toFixed(2)}
</td>
<td style={{ textAlign: 'center', fontFamily: FONT_MONO, color: C.dim }}>{net.fillRate}</td>
<td style={{ textAlign: 'center' }}>
<Box
component="span"
sx={{
...statusBadgeSx,
bgcolor:
net.status === 'active'
? 'rgba(34, 197, 94, 0.12)'
: 'rgba(56, 189, 248, 0.12)',
color: net.status === 'active' ? C.green400 : C.cyanLight,
borderColor:
net.status === 'active' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(56, 189, 248, 0.3)',
}}
>
{net.status.toUpperCase()}
</Box>
</td>
</tr>
))}
</tbody>
</Box> </Box>
</Box> </Box>
</Box> )
}
{/* Monthly Settlement & Tax Withholding Payout Ledger */}
<Box sx={{ ...panelSx, p: { xs: 2, md: 3 } }}> function RewardKpis({ stats }: { stats: AdRewardStats }): React.ReactElement {
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}> const cards = [
<Box> {
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}> title: `Rewarded Claims (${stats.windowDays}d)`,
Monthly Revenue Settlement & Payout Ledger value: stats.totalClaims.toLocaleString(),
</Typography> subtext: 'Verified rewarded-video completions'
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}> },
Net-30 / Net-60 cycle settlements with automatic 3.3% Korean withholding tax deduction. {
</Typography> title: `Tokens Granted (${stats.windowDays}d)`,
</Box> value: stats.totalRewardTokens.toLocaleString(),
<TactileBadge mono tone="success">TAX WITHHOLDING AUTO-CALCULATED</TactileBadge> subtext: 'Cloud AI tokens issued via ad rewards'
</Box> },
{
<Box sx={{ overflowX: 'auto' }}> title: `Unique Claimants (${stats.windowDays}d)`,
<Box component="table" sx={tableSx}> value: stats.uniqueClaimants.toLocaleString(),
<thead> subtext: 'Distinct accounts that redeemed rewards'
<tr> },
<th style={{ textAlign: 'left' }}>Settlement ID</th> {
<th style={{ textAlign: 'left' }}>Cycle Month</th> title: 'Live Ad Networks',
<th style={{ textAlign: 'left' }}>Network Source</th> value: `0 / ${MEDIATION_ROSTER.length}`,
<th style={{ textAlign: 'right' }}>Gross ($ USD)</th> subtext: 'All adapters fail-closed until SDK contracts land'
<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: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' }, gap: 2 }}>
</tr> {cards.map((card) => (
</thead> <Box key={card.title} sx={{ ...panelSx, p: 2.5 }}>
<tbody> <Typography
{settlements.map((s) => ( sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mb: 1, textTransform: 'uppercase', letterSpacing: '0.08em' }}
<tr key={s.id}> >
<td style={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight }}>{s.id}</td> {card.title}
<td style={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>{s.cycleMonth}</td> </Typography>
<td style={{ fontWeight: 600, color: C.bright }}>{s.networkName}</td> <Typography sx={{ fontFamily: FONT_MONO, fontSize: '22px', fontWeight: 700, color: C.bright, lineHeight: 1.25 }}>
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, color: C.bright }}> {card.value}
${s.grossUsd.toFixed(2)} </Typography>
</td> <Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>{card.subtext}</Typography>
<td style={{ textAlign: 'center', fontFamily: FONT_MONO, fontSize: '11px', color: C.orange400 }}> </Box>
{s.withholdingTax} ))}
</td> </Box>
<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> function MediationRosterPanel(): React.ReactElement {
<td style={{ textAlign: 'center' }}> return (
<Box <DoubleBezelCard bezelPadding="6px" innerPadding="24px">
component="span" <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1.5, mb: 1 }}>
sx={{ <Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright }}>
...statusBadgeSx, Mediation Waterfall Roster
bgcolor: </Typography>
s.payoutStatus === 'paid' <Box sx={statusBadgeSx('orange')}>NO LIVE BIDS</Box>
? 'rgba(34, 197, 94, 0.12)' </Box>
: 'rgba(234, 179, 8, 0.12)', <Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 2 }}>
color: s.payoutStatus === 'paid' ? C.green400 : C.orange400, . SDK/
borderColor: fail-closed로 , .
s.payoutStatus === 'paid' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(234, 179, 8, 0.3)', </Typography>
}} <Box sx={{ overflowX: 'auto' }}>
> <Box component="table" sx={tableSx}>
{s.payoutStatus.toUpperCase()} <Box component="thead">
</Box> <Box component="tr">
</td> <Box component="th">Network</Box>
</tr> <Box component="th">Adapter ID</Box>
))} <Box component="th">Formats</Box>
</tbody> <Box component="th">Floor eCPM</Box>
</Box> <Box component="th">Integration</Box>
</Box> </Box>
</Box> </Box>
</> <Box component="tbody">
{MEDIATION_ROSTER.map((network) => (
<Box component="tr" key={network.networkId}>
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.bright }}>
{network.name}
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
{network.networkId}
</Box>
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '12px' }}>
{network.formats.map((format) => FORMAT_LABEL[format] ?? format).join(' · ')}
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
${network.floorEcpm.toFixed(2)}
</Box>
<Box component="td">
<Box sx={statusBadgeSx('orange')}>NOT INTEGRATED</Box>
</Box>
</Box>
))}
</Box>
</Box>
</Box>
</DoubleBezelCard>
)
}
function RewardBreakdownPanel({ stats }: { stats: AdRewardStats }): React.ReactElement {
return (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1fr 1.4fr' }, gap: 3 }}>
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright, mb: 2 }}>
Rewarded Grants by Network
</Typography>
{stats.byNetwork.length === 0 ? (
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>
{stats.windowDays} .
</Typography>
) : (
<Box sx={{ overflowX: 'auto' }}>
<Box component="table" sx={tableSx}>
<Box component="thead">
<Box component="tr">
<Box component="th">Network</Box>
<Box component="th">Claims</Box>
<Box component="th">Tokens</Box>
</Box>
</Box>
<Box component="tbody">
{stats.byNetwork.map((summary) => (
<Box component="tr" key={summary.network}>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
{summary.network}
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
{summary.claims.toLocaleString()}
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.green400 }}>
+{summary.rewardTokens.toLocaleString()}
</Box>
</Box>
))}
</Box>
</Box>
</Box>
)}
</DoubleBezelCard>
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright, mb: 2 }}>
Recent Verified Claims
</Typography>
{stats.recentClaims.length === 0 ? (
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}> .</Typography>
) : (
<Box sx={{ overflowX: 'auto' }}>
<Box component="table" sx={tableSx}>
<Box component="thead">
<Box component="tr">
<Box component="th">Verified At (UTC)</Box>
<Box component="th">Network</Box>
<Box component="th">Placement</Box>
<Box component="th">Tokens</Box>
</Box>
</Box>
<Box component="tbody">
{stats.recentClaims.map((claim) => (
<Box component="tr" key={claim.id}>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
{formatDateTime(claim.verifiedAt)}
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
{claim.network}
</Box>
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '12px' }}>{claim.placement}</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.green400 }}>
+{claim.rewardTokens.toLocaleString()}
</Box>
</Box>
))}
</Box>
</Box>
</Box>
)}
</DoubleBezelCard>
</Box>
)
}
function RewardsUnavailablePanel({ reason }: { reason: string }): React.ReactElement {
return (
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '14px', fontWeight: 600, color: C.orange400, mb: 1 }}>
Rewarded .
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim }}>{reason}</Typography>
</DoubleBezelCard>
)
}
export default async function AdminAdsPage(): Promise<React.ReactElement> {
await requireManager()
let stats: AdRewardStats | null = null
let statsError: string | null = null
if (!isSupabaseAdminConfigured()) {
statsError =
'SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 ad_reward_claims 데이터에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다.'
} else {
try {
stats = await fetchAdRewardStats()
} catch (error) {
statsError = error instanceof Error ? error.message : 'Unknown ad reward stats error'
}
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<HeaderBar />
{stats && <RewardKpis stats={stats} />}
<MediationRosterPanel />
{stats ? <RewardBreakdownPanel stats={stats} /> : <RewardsUnavailablePanel reason={statsError ?? ''} />}
</Box>
) )
} }

View file

@ -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>

View file

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

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

View file

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

View file

@ -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) => {
const preset = PRESET_STT_PROVIDERS.find((p) => p.name === e.target.value)
if (!preset) return
setSttName(preset.name)
setSttProviderType(preset.providerType)
setSttEndpointUrl(preset.endpointUrl)
setSttModelId(preset.modelId)
setSttMethod(preset.method)
setSttLanguage(preset.language)
setSttCostPerMinute(String(preset.costPerMinute))
if (preset.prompt) setSttPrompt(preset.prompt)
}}
sx={{ 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 },
'&:hover fieldset': { borderColor: C.borderHl },
}} }}
> >
{PRESET_STT_PROVIDERS.map((p) => ( {PRESET_STT_PROVIDERS.map((preset) => (
<MenuItem key={p.name} value={p.name} sx={{ fontFamily: FONT_SANS, fontSize: '13px' }}> <MenuItem key={preset.name} value={preset.name}>
{p.name} ({p.providerType.toUpperCase()}) ${p.costPerMinute}/min {preset.name}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
</FormControl> </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>

View file

@ -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>
) : ( ) : (

View file

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

View file

@ -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 {
const [assets] = useState<ReleaseAsset[]>(INITIAL_ASSETS)
const [rolloutPercent, setRolloutPercent] = useState<number>(100)
const [forceUpdateEnabled, setForceUpdateEnabled] = useState<boolean>(false)
const [copiedId, setCopiedId] = useState<string | null>(null)
const copyToClipboard = (text: string, id: string): void => {
navigator.clipboard.writeText(text).then(() => {
setCopiedId(id)
setTimeout(() => setCopiedId(null), 2000)
})
} }
const totalDownloads = assets.reduce((acc, curr) => acc + curr.downloads, 0) function formatSize(sizeBytes: number): string {
if (sizeBytes <= 0) return '—'
if (sizeBytes < 1024 * 1024) return `${(sizeBytes / 1024).toFixed(1)} KB`
return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`
}
function formatDate(value: string): string {
if (!value) return '—'
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? '—' : parsed.toISOString().slice(0, 10)
}
function HeaderBar({ feedLive, repoHtmlUrl }: { feedLive: boolean; repoHtmlUrl: string }): React.ReactElement {
return ( return (
<Box sx={{ p: { xs: 2, md: 3 }, display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 0.5 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '24px', fontWeight: 800, color: C.bright }}>
Release & Distribution Hub
</Typography>
<Box <Box
sx={{ sx={{
px: 1.2, ...panelSx,
py: 0.3, minHeight: 84,
borderRadius: '6px', px: { xs: 2.5, md: 4 },
bgcolor: 'rgba(56, 189, 248, 0.15)', py: 2,
border: '1px solid rgba(56, 189, 248, 0.3)', display: 'flex',
color: C.cyanLight, flexWrap: 'wrap',
fontFamily: FONT_MONO, alignItems: 'center',
fontSize: '11px', justifyContent: 'space-between',
fontWeight: 700, gap: 2
}} }}
> >
v1.0.0 STABLE <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 &amp; Distribution Hub
</Typography>
{feedLive ? (
<TactileBadge ledColor="green" ledPulse tone="success" mono>
LIVE FORGEJO FEED
</TactileBadge>
) : (
<TactileBadge tone="warning" mono>
FEED UNREACHABLE
</TactileBadge>
)}
</Box> </Box>
</Box> <Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, letterSpacing: '0.04em', mt: 0.25 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}> Desktop &amp; mobile installers, SHA-256 integrity, download telemetry
Manage desktop installer distribution, multi-platform binaries, SHA-256 integrity checks, and auto-update feeds.
</Typography> </Typography>
</Box> </Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<Button <Button
href="https://git.chanpaca.net/yunchan/d3ro-voice/releases" component={Link}
href={`${repoHtmlUrl}/releases`}
target="_blank" target="_blank"
variant="outlined" variant="outlined"
startIcon={<ExternalLink size={14} />}
sx={{ sx={{
fontFamily: FONT_SANS, fontFamily: FONT_SANS,
fontSize: '12px', fontSize: '12px',
fontWeight: 600, fontWeight: 500,
color: C.text, color: C.text,
borderColor: C.border, borderColor: C.border,
textTransform: 'none', textTransform: 'none',
borderRadius: '10px', borderRadius: '10px',
'&:hover': { borderColor: C.accentLight, bgcolor: 'rgba(255, 255, 255, 0.05)' }, '&:hover': { borderColor: C.accentLight, color: C.bright }
}} }}
> >
Git Release Tags Forgejo Releases
</Button> </Button>
<Button <Button
href="http://localhost:3000/download.html" component={Link}
href="https://d3ro.chanpaca.net/download.html"
target="_blank" target="_blank"
variant="contained" variant="outlined"
startIcon={<ArrowUpRight size={14} />}
sx={{ sx={{
fontFamily: FONT_SANS, fontFamily: FONT_SANS,
fontSize: '12px', fontSize: '12px',
fontWeight: 700, fontWeight: 500,
color: '#000', color: C.cyanLight,
bgcolor: C.cyanLight, borderColor: C.border,
textTransform: 'none', textTransform: 'none',
borderRadius: '10px', borderRadius: '10px',
'&:hover': { bgcolor: '#7dd3fc' }, '&:hover': { borderColor: C.cyanLight, color: C.bright }
}} }}
> >
View Public Download Page Public Download Page
</Button> </Button>
</Box> </Box>
</Box> </Box>
)
{/* KPI Cards */} }
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: 'repeat(4, 1fr)' }, gap: 2 }}>
<Box sx={{ p: 2.5, borderRadius: '16px', bgcolor: C.card, border: `1px solid ${C.border}` }}> function KpiCards({ hub }: { hub: ReleaseHub }): React.ReactElement {
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 1, textTransform: 'uppercase' }}> const latest = hub.latestStable
Total App Downloads const latestPlatforms = [...new Set(latest?.assets.map((asset) => asset.platform) ?? [])].filter(
</Typography> (platform) => platform !== 'other'
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '26px', fontWeight: 800, color: C.bright }}> )
{totalDownloads.toLocaleString()} const cards = [
</Typography> {
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: '#34d399', mt: 0.5 }}> title: 'Total Asset Downloads',
+18.4% WoW (Free & Pro Installs) value: hub.totalDownloads.toLocaleString(),
</Typography> subtext: 'Forgejo attachment download counters'
</Box> },
{
<Box sx={{ p: 2.5, borderRadius: '16px', bgcolor: C.card, border: `1px solid ${C.border}` }}> title: 'Latest Stable Release',
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 1, textTransform: 'uppercase' }}> value: latest?.tagName ?? '—',
Auto-Update Feed Health subtext: latest ? `Published ${formatDate(latest.publishedAt)}` : 'No stable release published'
</Typography> },
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> {
<CheckCircle2 size={20} color="#34d399" /> title: 'Published Releases',
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 800, color: C.bright }}> value: hub.releases.length.toLocaleString(),
200 OK (Live) subtext: `${hub.prereleaseCount.toLocaleString()} pre-release channel builds`
</Typography> },
</Box> {
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight, mt: 0.5 }}> title: 'Latest Platform Coverage',
latest.yml generic feed value: latestPlatforms.length ? latestPlatforms.map((platform) => PLATFORM_LABEL[platform]).join(' · ') : '—',
</Typography> subtext: latest ? `${latest.assets.length.toLocaleString()} packaged artifacts` : 'Awaiting first artifact upload'
</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' }}> return (
Synology NAS Storage <Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' }, gap: 2 }}>
</Typography> {cards.map((card) => (
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '26px', fontWeight: 800, color: C.bright }}> <Box key={card.title} sx={{ ...panelSx, p: 2.5 }}>
2.4 GB / 8 TB <Typography
</Typography> sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mb: 1, textTransform: 'uppercase', letterSpacing: '0.08em' }}
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}> >
/volume1/docker/d3ro-voice {card.title}
</Typography> </Typography>
</Box> <Typography sx={{ fontFamily: FONT_MONO, fontSize: '22px', fontWeight: 700, color: C.bright, lineHeight: 1.25 }}>
{card.value}
<Box sx={{ p: 2.5, borderRadius: '16px', bgcolor: C.card, border: `1px solid ${C.border}` }}> </Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 1, textTransform: 'uppercase' }}> <Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>{card.subtext}</Typography>
Active Release Version </Box>
</Typography> ))}
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '26px', fontWeight: 800, color: C.purple400 }}> </Box>
1.0.0 )
</Typography> }
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
Deployed: 2026-08-20 function LatestAssetsPanel({ latest }: { latest: ReleaseHub['latestStable'] }): React.ReactElement {
</Typography> return (
</Box> <DoubleBezelCard bezelPadding="6px" innerPadding="24px">
</Box> <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 }}>
{/* Phased Rollout & Control Card */} Latest Binary Packages &amp; Checksums
<Box </Typography>
sx={{ {latest && (
p: 3, <Box sx={statusBadgeSx('cyan')}>{latest.tagName}</Box>
borderRadius: '20px', )}
bgcolor: 'rgba(17, 26, 48, 0.7)', </Box>
backdropFilter: 'blur(16px)',
border: `1px solid ${C.border}`, {!latest || latest.assets.length === 0 ? (
}} <Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>
> .
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2, mb: 3 }}> </Typography>
<Box> ) : (
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}> <Box sx={{ overflowX: 'auto' }}>
Phased Rollout & Auto-Update Policy <Box component="table" sx={tableSx}>
</Typography> <Box component="thead">
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim }}> <Box component="tr">
Control automatic background update delivery to client desktop installations. <Box component="th">Artifact</Box>
</Typography> <Box component="th">Platform</Box>
</Box> <Box component="th">Size</Box>
<Box component="th">Downloads</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3 }}> <Box component="th">SHA-256</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box component="th">Link</Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.text }}> </Box>
Force Update: </Box>
</Typography> <Box component="tbody">
<Switch {latest.assets.map((asset) => (
checked={forceUpdateEnabled} <Box component="tr" key={asset.id}>
onChange={(e) => setForceUpdateEnabled(e.target.checked)} <Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
size="small" {asset.name}
/> </Box>
</Box> <Box component="td">
<Box sx={statusBadgeSx(PLATFORM_BADGE[asset.platform])}>{PLATFORM_LABEL[asset.platform]}</Box>
<Box </Box>
sx={{ <Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>{formatSize(asset.sizeBytes)}</Box>
px: 1.5, <Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
py: 0.5, {asset.downloadCount.toLocaleString()}
borderRadius: '8px', </Box>
bgcolor: 'rgba(34, 197, 94, 0.12)', <Box component="td">
border: '1px solid rgba(34, 197, 94, 0.3)', {asset.sha256 ? (
color: '#34d399', <ChecksumCopy value={asset.sha256} />
fontFamily: FONT_MONO, ) : (
fontSize: '12px', <Typography component="span" sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.muted }}>
fontWeight: 700, not published
}} </Typography>
> )}
Rollout: {rolloutPercent}% </Box>
</Box> <Box component="td">
</Box> <Typography
</Box> component="a"
href={asset.browserDownloadUrl}
<Box sx={{ mb: 2 }}> target="_blank"
<LinearProgress rel="noreferrer"
variant="determinate" sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.cyanLight, textDecoration: 'none', '&:hover': { color: C.bright } }}
value={rolloutPercent} >
sx={{ Download
height: 8, </Typography>
borderRadius: '4px', </Box>
bgcolor: 'rgba(255, 255, 255, 0.05)', </Box>
'& .MuiLinearProgress-bar': { bgcolor: C.cyanLight }, ))}
}} </Box>
/> </Box>
</Box> </Box>
)}
<Box sx={{ display: 'flex', gap: 1 }}> </DoubleBezelCard>
{[10, 25, 50, 100].map((pct) => ( )
<Button }
key={pct}
size="small" function ReleaseHistoryPanel({ hub }: { hub: ReleaseHub }): React.ReactElement {
onClick={() => setRolloutPercent(pct)} return (
sx={{ <DoubleBezelCard bezelPadding="6px" innerPadding="24px">
fontFamily: FONT_MONO, <Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright, mb: 2 }}>
fontSize: '11px', Release Channel History
fontWeight: 600, </Typography>
color: rolloutPercent === pct ? '#000' : C.text, {hub.releases.length === 0 ? (
bgcolor: rolloutPercent === pct ? C.cyanLight : 'rgba(255, 255, 255, 0.04)', <Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}> .</Typography>
border: `1px solid ${rolloutPercent === pct ? C.cyanLight : C.border}`, ) : (
borderRadius: '8px', <Box sx={{ overflowX: 'auto' }}>
textTransform: 'none', <Box component="table" sx={tableSx}>
'&:hover': { bgcolor: rolloutPercent === pct ? '#7dd3fc' : 'rgba(255, 255, 255, 0.08)' }, <Box component="thead">
}} <Box component="tr">
> <Box component="th">Tag</Box>
Set {pct}% <Box component="th">Release</Box>
</Button> <Box component="th">Published</Box>
))} <Box component="th">Channel</Box>
</Box> <Box component="th">Assets</Box>
</Box> <Box component="th">Downloads</Box>
<Box component="th">Source</Box>
{/* Release Assets Table */} </Box>
<Box </Box>
sx={{ <Box component="tbody">
borderRadius: '20px', {hub.releases.map((release) => (
bgcolor: 'rgba(17, 26, 48, 0.7)', <Box component="tr" key={release.id}>
backdropFilter: 'blur(16px)', <Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
border: `1px solid ${C.border}`, {release.tagName}
overflow: 'hidden', </Box>
}} <Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.bright }}>
> {release.name}
<Box sx={{ p: 2.5, borderBottom: `1px solid ${C.border}` }}> </Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 700, color: C.bright }}> <Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>{formatDate(release.publishedAt)}</Box>
Published Binary Packages & Checksums <Box component="td">
</Typography> <Box sx={statusBadgeSx(release.isPrerelease ? 'orange' : 'green')}>
</Box> {release.isPrerelease ? 'PRE-RELEASE' : 'STABLE'}
</Box>
<Box sx={{ overflowX: 'auto' }}> </Box>
<Box component="table" sx={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}> <Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
<Box component="thead"> {release.assets.length.toLocaleString()}
<Box component="tr" sx={{ borderBottom: `1px solid ${C.border}`, bgcolor: 'rgba(0, 0, 0, 0.2)' }}> </Box>
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Artifact Name</Box> <Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Platform</Box> {release.downloadCount.toLocaleString()}
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Version</Box> </Box>
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Size</Box> <Box component="td">
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Downloads</Box> <Typography
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>SHA-256 Checksum</Box> component="a"
<Box component="th" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, fontWeight: 600, textTransform: 'uppercase' }}>Status</Box> href={release.htmlUrl}
</Box> target="_blank"
</Box> rel="noreferrer"
sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.cyanLight, textDecoration: 'none', '&:hover': { color: C.bright } }}
<Box component="tbody"> >
{assets.map((asset) => ( Forgejo
<Box </Typography>
component="tr" </Box>
key={asset.id} </Box>
sx={{ ))}
borderBottom: `1px solid ${C.border}`, </Box>
'&:hover': { bgcolor: 'rgba(255, 255, 255, 0.02)' }, </Box>
}} </Box>
> )}
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '12px', fontWeight: 600, color: C.bright }}> </DoubleBezelCard>
{asset.name} )
</Box> }
<Box component="td" sx={{ p: 2, fontFamily: FONT_SANS, fontSize: '12px', color: C.text }}>
{asset.os} export default async function ReleasesManagementPage(): Promise<React.ReactElement> {
</Box> await requireManager()
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
{asset.version} let hub: ReleaseHub | null = null
</Box> let feedError: string | null = null
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}> try {
{asset.sizeMb} MB hub = await fetchReleaseHub()
</Box> } catch (error) {
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '12px', fontWeight: 700, color: C.bright }}> feedError = error instanceof Error ? error.message : 'Unknown release feed error'
{asset.downloads.toLocaleString()} }
</Box>
<Box component="td" sx={{ p: 2, fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}> if (!hub) {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> return (
<span style={{ maxWidth: '160px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{asset.sha256} <HeaderBar feedLive={false} repoHtmlUrl="https://git.chanpaca.net/yunchan/d3ro-voice" />
</span> <DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box <Typography sx={{ fontFamily: FONT_SANS, fontSize: '14px', fontWeight: 600, color: C.orange400, mb: 1 }}>
onClick={() => copyToClipboard(asset.sha256, asset.id)} Forgejo .
sx={{ cursor: 'pointer', color: copiedId === asset.id ? '#34d399' : C.cyanLight, '&:hover': { color: '#fff' } }} </Typography>
> <Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>{feedError}</Typography>
{copiedId === asset.id ? <CheckCircle2 size={13} /> : <Copy size={13} />} <Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mt: 1.5 }}>
</Box> RELEASE_REPO_URL . .
</Box> </Typography>
</Box> </DoubleBezelCard>
<Box component="td" sx={{ p: 2 }}> </Box>
<Box )
sx={{ }
display: 'inline-block',
px: 1, return (
py: 0.3, <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
borderRadius: '6px', <HeaderBar feedLive repoHtmlUrl={hub.repoHtmlUrl} />
fontSize: '10px', <KpiCards hub={hub} />
fontFamily: FONT_MONO, <LatestAssetsPanel latest={hub.latestStable} />
fontWeight: 700, <ReleaseHistoryPanel hub={hub} />
textTransform: 'uppercase', </Box>
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}
</Box>
</Box>
</Box>
))}
</Box>
</Box>
</Box>
</Box>
</Box>
) )
} }

View file

@ -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"

View file

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

View file

@ -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)',

View file

@ -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,

View file

@ -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>> = []
for (let page = 0; page < 100; page += 1) {
let query = supabase
.from('subscriptions') .from('subscriptions')
.select('id, user_id, tier, status, payment_provider, current_period_end, cancel_at') .select('id, user_id, tier, status, payment_provider, current_period_end, cancel_at, renewal_failures')
.order('current_period_end', { ascending: true }) .order('current_period_end', { ascending: true })
.limit(100) .range(page * 500, page * 500 + 499)
if (statusFilter !== 'all') query = query.eq('status', statusFilter)
if (statusFilter !== 'all') subQuery.eq('status', statusFilter) const { data, error } = await query
if (error) throw new Error('Supabase subscriptions failed')
const { data: rawSubs } = await subQuery rawSubsArr.push(...((data ?? []) as Array<Record<string, unknown>>))
const rawSubsArr = (rawSubs ?? []) 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 />}
{hasMinRole(admin, 'admin') && (
<Link href="/subscriptions/new" style={{ textDecoration: 'none' }}> <Link href="/subscriptions/new" style={{ textDecoration: 'none' }}>
<Button variant="contained" size="small" sx={primaryButtonSx}> <Button variant="contained" size="small" sx={primaryButtonSx}>
+ Create Subscription + Create Subscription
</Button> </Button>
</Link> </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' }}>

View file

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

View file

@ -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>

View file

@ -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,34 +164,45 @@ 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>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
<TactileBadge tone="mono" mono>{sub ? 'SUPABASE SUBSCRIPTION' : 'NO ACTIVE CONTRACT RECORD'}</TactileBadge>
</Box>
</Box>
</DoubleBezelCard>
</Box>
{/* Tier-based Enabled Features */}
<Box sx={{ p: 2, borderRadius: '12px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', fontWeight: 600, color: C.bright, mb: 1 }}>
Enabled Features
</Typography> </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="success" mono>Whisper Turbo STT (Unlimited)</TactileBadge>
@ -205,13 +213,11 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
{isProPlus && <TactileBadge tone="success" mono>Pyannote Diarization</TactileBadge>} {isProPlus && <TactileBadge tone="success" mono>Pyannote Diarization</TactileBadge>}
</Box> </Box>
</Box> </Box>
</DoubleBezelCard>
</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} />

View file

@ -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 */}

View 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

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

View 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

View file

@ -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() if (!body || typeof body !== 'object' || Array.isArray(body)) {
const { usernameOrEmail, password, trap } = body return failure('invalid_request', 400)
// 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) { const candidate = body as Record<string, unknown>
return NextResponse.json( if (candidate.trap) return failure('access_denied', 403)
{ success: false, message: '아이디와 비밀번호를 입력해주세요.' }, const email =
{ status: 400 } 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 trimmedUser = String(usernameOrEmail).trim().toLowerCase() const rateLimitKey = clientIdentifier(request, email)
const trimmedPass = String(password).trim()
const rateLimitKey = `${clientIp}:${trimmedUser}`
// 3. Check Sliding-Window Rate Limiter
const rateCheck = checkRateLimit(rateLimitKey) const rateCheck = checkRateLimit(rateLimitKey)
if (!rateCheck.allowed) { if (!rateCheck.allowed) {
return NextResponse.json( return failure('rate_limited', 429, rateCheck.retryAfterSeconds)
{
success: false,
message: `로그인 시도 횟수를 초과했습니다. 보안을 위해 ${rateCheck.retryAfterSeconds}초 후 다시 시도해주세요.`,
retryAfter: rateCheck.retryAfterSeconds,
},
{
status: 429,
headers: { 'Retry-After': String(rateCheck.retryAfterSeconds) },
}
)
} }
let authenticated = false let backend: BackendAuthResponse
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 { try {
const apiRes = await fetch(`${API_BASE}/api/auth/login`, { const response = await fetch(`${apiBase()}/api/auth/login`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: trimmedUser, password: trimmedPass }), body: JSON.stringify({ email, password }),
cache: 'no-store',
signal: AbortSignal.timeout(7_000)
}) })
if (!response.ok) throw new Error('invalid_credentials')
if (apiRes.ok) { backend = (await response.json()) as BackendAuthResponse
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 { } catch {
// Fallback 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)
} }
// 6. Handle Authentication Failure (Anti-Brute Force Tracking) const role = normalizeRole(backend.role)
if (!authenticated) { const token = typeof backend.token === 'string' ? backend.token : ''
const lockResult = recordFailedAttempt(rateLimitKey) const authenticatedEmail =
// Dynamic delay to prevent timing analysis typeof backend.email === 'string' ? backend.email.trim().toLowerCase() : ''
await new Promise((resolve) => setTimeout(resolve, 600)) const backendExpiry =
typeof backend.expiresAt === 'string' ? Date.parse(backend.expiresAt) : Number.NaN
if (lockResult.locked) { if (
return NextResponse.json( !role ||
{ token.length < 80 ||
success: false, token.length > 8_192 ||
message: `5회 이상 잘못된 비밀번호가 입력되었습니다. 보안을 위해 계정이 ${lockResult.retryAfterSeconds}초 동안 잠깁니다.`, !authenticatedEmail ||
retryAfter: lockResult.retryAfterSeconds, authenticatedEmail !== email ||
}, !Number.isFinite(backendExpiry) ||
{ backendExpiry <= Date.now()
status: 429, ) {
headers: { 'Retry-After': String(lockResult.retryAfterSeconds) }, return failure('invalid_auth_response', 502)
}
)
} }
return NextResponse.json(
{ success: false, message: '아이디 또는 비밀번호가 올바르지 않습니다.' },
{ status: 401 }
)
}
// 7. Successful Authentication -> Clear Rate Limiting
resetFailedAttempts(rateLimitKey) resetFailedAttempts(rateLimitKey)
const expiresAt = Math.min(backendExpiry, Date.now() + 8 * 60 * 60 * 1_000)
// 8. Generate Cryptographically Signed HMAC Session Token const signedCookieValue = signSession({
const sessionData = { email: authenticatedEmail,
id: 'admin-usr-1', role,
username: 'admin',
email: userEmail,
role: userRole,
token, token,
loginAt: new Date().toISOString(), loginAt: new Date().toISOString(),
expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days expiresAt
} })
const signedCookieValue = signSession(sessionData)
const response = NextResponse.json({ const response = NextResponse.json({
success: true, success: true,
user: { user: { email: authenticatedEmail, role }
id: sessionData.id,
email: sessionData.email,
role: sessionData.role,
},
}) })
// 9. Set Hardened HttpOnly SameSite=Strict Cookie
response.cookies.set({ response.cookies.set({
name: 'd3ro_admin_session', name: 'd3ro_admin_session',
value: signedCookieValue, value: signedCookieValue,
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: adminCookieSecure(),
sameSite: 'lax', sameSite: 'strict',
path: '/', path: '/',
maxAge: 30 * 24 * 60 * 60, maxAge: Math.max(1, Math.floor((expiresAt - Date.now()) / 1_000))
}) })
return response return response
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error'
return NextResponse.json({ success: false, message }, { status: 500 })
}
} }

View file

@ -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,

View file

@ -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;

View file

@ -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" />

View file

@ -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)',

View file

@ -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,
}} }}

View file

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

View file

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

View file

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

View file

@ -1,11 +1,11 @@
'use client' '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

View file

@ -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') {
expiresAt = now + 365 * 24 * 60 * 60 * 1000
}
const payload = {
licenseId: `lic-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 6)}`,
tier,
customerEmail: customerEmail.trim(), customerEmail: customerEmail.trim(),
issuedAt: now, tier,
expiresAt, validity,
machineId: machineId.trim() || null, machineId: machineId.trim(),
teamId: teamId.trim() || undefined, teamId: teamId.trim()
maxDevices: tier === 'enterprise' ? 999 : tier === 'team' ? 25 : tier === 'pro_plus' ? 5 : 3, })
})
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 key = issueSignedLicenseKey(payload, DEFAULT_LICENSE_PRIVATE_KEY) setUsedDefaultKey(data.usedDefaultKey === true)
setGeneratedKey(key) setAuditRecorded(data.auditRecorded !== false)
} 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'}

View file

@ -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> </Box>
{data.paypleHistory ? ( ))}
<Box sx={{ <PhosphorText variant="dim" sx={{ display: 'block', mt: 1 }}>
fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, Live provider lookup is not connected. Raw Payple responses and provider identifiers are never exposed here.
bgcolor: d3roPalette.bg.inset, borderRadius: 1, p: 1, maxHeight: 300, overflow: 'auto', </PhosphorText>
whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: d3roPalette.text.primary,
}}>
{JSON.stringify(data.paypleHistory, null, 2)}
</Box> </Box>
) : data.paypleError ? ( </MetalCard>
<PhosphorText variant="dim" sx={{ color: d3roPalette.tag.red }}>{data.paypleError}</PhosphorText>
) : ( <MetalCard>
<PhosphorText variant="dim">Click &quot;Fetch from Payple&quot; to query payment history</PhosphorText> <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>

View file

@ -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"

View file

@ -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>

View file

@ -0,0 +1,37 @@
import { Alert, Box, Typography } from '@mui/material'
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
import { C, FONT_MONO, FONT_SANS, panelSx } from '@/lib/console-theme'
interface UnavailableAdminPanelProps {
title: string
capability: string
reason: string
}
export function UnavailableAdminPanel({ title, capability, reason }: UnavailableAdminPanelProps): React.ReactElement {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box sx={{ ...panelSx, minHeight: 84, px: { xs: 2.5, md: 4 }, py: 2, display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ width: 4, height: 32, borderRadius: '999px', bgcolor: C.orange400 }} />
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography component="h1" sx={{ fontFamily: FONT_SANS, fontSize: '20px', fontWeight: 500, color: C.bright }}>
{title}
</Typography>
<TactileBadge tone="warning" mono>NOT CONNECTED</TactileBadge>
</Box>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, mt: 0.25 }}>
{capability}
</Typography>
</Box>
</Box>
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Alert severity="warning">
<Typography sx={{ fontWeight: 500, mb: 0.5 }}> .</Typography>
<Typography>{reason}</Typography>
<Typography sx={{ mt: 1 }}> , .</Typography>
</Alert>
</DoubleBezelCard>
</Box>
)
}

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

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

View file

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

View file

@ -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,20 +24,16 @@ 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') redirect('/login')
} }
return { return {
id: decoded.id || 'admin-usr-1', id: session.email,
email: decoded.email || 'admin@d3ro.voice', email: session.email,
name: decoded.username === 'admin' ? 'Master Admin' : decoded.email, name: session.email,
role: (decoded.role as AdminRole) || 'super_admin', role: session.role
}
} catch {
redirect('/login')
} }
} }

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

View file

@ -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 }
ragVector: { embeddingModel: string; indexedDocuments: number; totalVectorChunks: number; avgSearchLatencyMs: number; topHitRatePercent: number }
meetingIntelligence: { diarizationEngine: string; speakerAccuracyPercent: number; activeMeetingSessions: number; templatesGeneratedToday: number; mindmapsExported: number }
}
export interface FeatureUsageBreakdown {
featureId: string
featureName: string
category: string
totalCalls?: number
callCount?: number
percentage?: number
tokensUsed: number
totalCost: number
estimatedCostUsd?: number
avgLatencyMs: number avgLatencyMs: number
speedupFactor: string
partialStreamingFps: number
totalTranscriptionsToday: number
gpuVramUsage: string
}
ollama: {
version: string
loadedModels: string[]
activeContextLimit: number
tokensPerSecond: number
vramAllocated: string
activeSessions: number
}
realtimeVoice: {
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 {
totalUsers: data.totalUsers ?? 4580,
activeUsersToday: data.activeUsersToday ?? 1240,
totalRequests: data.totalRequests ?? 142890,
totalCost: data.totalCost ?? 24.8912,
serverUptimeSeconds: data.serverUptimeSeconds ?? 864200,
errorCount: data.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: data.recentErrors ?? [],
} }
} catch {
return { return {
totalUsers: 4580, totalUsers: data.totalUsers,
activeUsersToday: 1240, activeUsersToday: data.activeUsersToday,
totalRequests: 142890, totalRequests: data.totalRequests,
totalCost: 24.8912, totalCost: data.totalCost,
serverUptimeSeconds: 864200, serverUptimeSeconds: data.serverUptimeSeconds,
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: [], recentErrors: Array.isArray(data.recentErrors) ? data.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
}
}

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

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

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

View file

@ -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,

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

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

View file

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

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

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

View file

@ -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
View 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
View 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')

View file

@ -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,
"model_endpoint",
_ => dto.ModelId.Trim(),
dto.Memo,
() => Task.FromResult<object?>(null),
async () =>
{ {
return Conflict(new { message = "이미 존재하는 ModelId입니다." }); if (await _db.ModelEndpoints.AnyAsync(model => model.ModelId == dto.ModelId.Trim()))
} throw new AdminOperationException("model_id_already_exists");
var endpoint = new ServiceModelEndpoint var endpoint = new ServiceModelEndpoint
{ {
ModelId = dto.ModelId.Trim(), ModelId = dto.ModelId.Trim(),
ModelName = dto.ModelName.Trim(), ModelName = dto.ModelName.Trim(),
Provider = dto.Provider.Trim(), Provider = dto.Provider.Trim(),
EndpointUrl = dto.EndpointUrl.Trim(), EndpointUrl = dto.EndpointUrl.Trim(),
ApiKey = dto.ApiKey ?? "", ApiKey = dto.ApiKey?.Trim() ?? "",
CostPer1kPromptTokens = dto.CostPer1kPromptTokens, CostPer1kPromptTokens = dto.CostPer1kPromptTokens,
CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens, CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens,
IsActive = true, IsActive = true,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
_db.ModelEndpoints.Add(endpoint); _db.ModelEndpoints.Add(endpoint);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return ToModelEndpointDto(endpoint);
return Ok(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 = "유효한 모델 엔드포인트 값이 필요합니다." });
return await ExecuteAdminMutationAsync(
"model_endpoint.update",
new { id, dto },
"model_endpoint",
_ => id.ToString(),
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");
endpoint.ModelName = dto.ModelName.Trim(); endpoint.ModelName = dto.ModelName.Trim();
endpoint.Provider = dto.Provider.Trim(); endpoint.Provider = dto.Provider.Trim();
endpoint.EndpointUrl = dto.EndpointUrl.Trim(); endpoint.EndpointUrl = dto.EndpointUrl.Trim();
endpoint.ApiKey = dto.ApiKey ?? ""; if (dto.ApiKey != null) endpoint.ApiKey = dto.ApiKey.Trim();
endpoint.CostPer1kPromptTokens = dto.CostPer1kPromptTokens; endpoint.CostPer1kPromptTokens = dto.CostPer1kPromptTokens;
endpoint.CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens; endpoint.CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens;
endpoint.IsActive = dto.IsActive; endpoint.IsActive = dto.IsActive;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return Ok(endpoint); 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",
new { id, dto.Memo },
"model_endpoint",
_ => id.ToString(),
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); _db.ModelEndpoints.Remove(endpoint);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return new { message = "삭제되었습니다.", id };
return Ok(new { message = "삭제되었습니다." }); });
} }
// ── 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)
return BadRequest(new { message = "유효한 STT 엔드포인트 값이 필요합니다." });
return await ExecuteAdminMutationAsync(
"stt_endpoint.update",
new { id, dto },
"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 () =>
{ {
var endpoint = await _sttService.UpdateEndpointAsync(id, dto); try { return await _sttService.UpdateEndpointAsync(id, dto); }
return Ok(endpoint); catch (KeyNotFoundException) { throw new AdminOperationException("stt_endpoint_not_found"); }
} });
catch (KeyNotFoundException)
{
return NotFound(new { message = $"STT Endpoint {id} 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" });
}
}
} }

View file

@ -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
); );

View file

@ -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();
}
} }
if (app.Environment.IsDevelopment())
{
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); 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 { }

View file

@ -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:

View file

@ -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: