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
FROM node:20-alpine AS base
FROM node:24-alpine AS base
FROM base AS builder
WORKDIR /app
COPY package*.json tsconfig*.json ./
COPY packages ./packages
COPY apps/admin ./apps/admin
RUN npm install
RUN npm ci
RUN npm run build --workspace=@d3ro/admin
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/static ./apps/admin/.next/static
COPY apps/admin/start.mjs ./apps/admin/start.mjs
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",
"@supabase/ssr": "^0.10.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-dom": "^19.0.0",
"recharts": "^2.15.0"

View file

@ -1,242 +1,29 @@
// apps/admin/src/app/(admin)/ads/page.tsx
// D3RO Voice — Multi-Ad Network Mediation & Revenue Settlement Console (10+ Demand Sources)
// D3RO Voice Admin CRM — Ad Mediation & Monetization Console
import React from 'react'
import { Box, Typography, Button } from '@mui/material'
import {
C,
FONT_SANS,
FONT_MONO,
panelSx,
tableSx,
statusBadgeSx,
primaryButtonSx,
} from '@/lib/console-theme'
import { Box, Typography } from '@mui/material'
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
import { requireManager } from '@/lib/admin-guard'
import { MEDIATION_ROSTER, fetchAdRewardStats, type AdRewardStats } from '@/lib/ad-monetization'
import { isSupabaseAdminConfigured } from '@/lib/supabase-admin'
interface AdNetworkStat {
id: string
name: string
adapterType: string
format: string
impressions: number
clicks: number
ctr: string
ecpm: number
grossRevenueUsd: number
fillRate: string
status: 'active' | 'bidding' | 'fallback'
export const dynamic = 'force-dynamic'
const FORMAT_LABEL: Record<string, string> = {
banner_dock: 'Bottom Dock',
rewarded_video: 'Rewarded Video',
export_sponsor: 'Export Sponsor',
audio_chime: 'Audio Chime'
}
interface SettlementRow {
id: string
cycleMonth: string
networkName: string
grossUsd: number
withholdingTax: string
netPayoutKrw: number
payoutStatus: 'settled' | 'paid' | 'pending'
method: string
function formatDateTime(value: string): string {
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? '—' : parsed.toISOString().replace('T', ' ').slice(0, 16)
}
export default async function AdminAdsPage(): Promise<React.ReactElement> {
// 10+ Production Ad Networks Active in D3RO Voice Mediation
const networks: AdNetworkStat[] = [
{
id: 'net_001',
name: 'Direct House Sponsor Engine',
adapterType: 'Direct Contract',
format: 'Bottom Dock / Video / Export',
impressions: 84000,
clicks: 4200,
ctr: '5.0%',
ecpm: 15.2,
grossRevenueUsd: 1276.8,
fillRate: '100.0%',
status: 'active',
},
{
id: 'net_002',
name: 'Playwire RAMP Engine',
adapterType: 'Header Bidding SSP',
format: 'Desktop Video & Display',
impressions: 62000,
clicks: 4340,
ctr: '7.0%',
ecpm: 8.4,
grossRevenueUsd: 520.8,
fillRate: '96.2%',
status: 'bidding',
},
{
id: 'net_003',
name: 'AppLovin MAX',
adapterType: 'Real-Time In-App Bidding',
format: 'Rewarded Video (15s)',
impressions: 48000,
clicks: 3840,
ctr: '8.0%',
ecpm: 7.8,
grossRevenueUsd: 374.4,
fillRate: '94.5%',
status: 'bidding',
},
{
id: 'net_004',
name: 'Unity LevelPlay',
adapterType: 'Rewarded Video SDK',
format: 'Rewarded Quota Refill',
impressions: 45000,
clicks: 4050,
ctr: '9.0%',
ecpm: 9.1,
grossRevenueUsd: 409.5,
fillRate: '95.1%',
status: 'active',
},
{
id: 'net_005',
name: 'EthicalAds Privacy Dev Network',
adapterType: 'REST Decision API',
format: 'Bottom Dock Banner',
impressions: 38000,
clicks: 608,
ctr: '1.6%',
ecpm: 3.8,
grossRevenueUsd: 144.4,
fillRate: '99.4%',
status: 'active',
},
{
id: 'net_006',
name: 'Carbon Ads (BuySellAds)',
adapterType: 'Native JSON Endpoint',
format: 'Tech Developer Unit',
impressions: 31000,
clicks: 589,
ctr: '1.9%',
ecpm: 4.2,
grossRevenueUsd: 130.2,
fillRate: '98.8%',
status: 'active',
},
{
id: 'net_007',
name: 'Google Ad Manager 360',
adapterType: 'Universal Ad Server',
format: 'Global Display & Video',
impressions: 29000,
clicks: 580,
ctr: '2.0%',
ecpm: 3.5,
grossRevenueUsd: 101.5,
fillRate: '99.8%',
status: 'bidding',
},
{
id: 'net_008',
name: 'Mintegral APAC Network',
adapterType: 'Video & Playable SDK',
format: 'Rewarded Video (15s)',
impressions: 22000,
clicks: 1760,
ctr: '8.0%',
ecpm: 6.2,
grossRevenueUsd: 136.4,
fillRate: '92.4%',
status: 'bidding',
},
{
id: 'net_009',
name: 'InMobi Programmatic Exchange',
adapterType: 'RTB Exchange',
format: 'Banner & Video Interstitial',
impressions: 19000,
clicks: 380,
ctr: '2.0%',
ecpm: 3.4,
grossRevenueUsd: 64.6,
fillRate: '91.2%',
status: 'active',
},
{
id: 'net_010',
name: 'PubMatic OpenWrap SSP',
adapterType: 'Prebid Header Bidding',
format: 'Bottom Dock & Export Modal',
impressions: 15000,
clicks: 225,
ctr: '1.5%',
ecpm: 3.6,
grossRevenueUsd: 54.0,
fillRate: '90.5%',
status: 'bidding',
},
]
const totalGrossRevenue = networks.reduce((acc, n) => acc + n.grossRevenueUsd, 0)
const totalImpressions = networks.reduce((acc, n) => acc + n.impressions, 0)
const avgEcpm = (totalGrossRevenue / (totalImpressions / 1000)).toFixed(2)
// Monthly Settlement Records (KRW Tax Withholding & Net Payout)
const settlements: SettlementRow[] = [
{
id: 'STL-202607-DIRECT',
cycleMonth: '2026-07',
networkName: 'Direct House Sponsor (Cursor/Notion)',
grossUsd: 1276.8,
withholdingTax: '3.3% (₩56,870)',
netPayoutKrw: 1666810,
payoutStatus: 'paid',
method: 'KB국민 928702-00-184920',
},
{
id: 'STL-202607-PLAYWIRE',
cycleMonth: '2026-07',
networkName: 'Playwire RAMP Desktop Header Bidding',
grossUsd: 520.8,
withholdingTax: '3.3% (₩23,200)',
netPayoutKrw: 679880,
payoutStatus: 'paid',
method: 'Wire Transfer (USD)',
},
{
id: 'STL-202607-APPLOVIN',
cycleMonth: '2026-07',
networkName: 'AppLovin MAX In-App Bidding',
grossUsd: 374.4,
withholdingTax: '3.3% (₩16,680)',
netPayoutKrw: 488760,
payoutStatus: 'settled',
method: 'Wire Transfer (USD)',
},
{
id: 'STL-202607-UNITY',
cycleMonth: '2026-07',
networkName: 'Unity LevelPlay Rewarded Video',
grossUsd: 409.5,
withholdingTax: '3.3% (₩18,240)',
netPayoutKrw: 534580,
payoutStatus: 'settled',
method: 'PayPal (yunchanpaca@gmail.com)',
},
{
id: 'STL-202607-ETHICAL',
cycleMonth: '2026-07',
networkName: 'EthicalAds Privacy Dev Network',
grossUsd: 144.4,
withholdingTax: '3.3% (₩6,430)',
netPayoutKrw: 188510,
payoutStatus: 'settled',
method: 'PayPal (yunchanpaca@gmail.com)',
},
]
const totalSettledKrw = settlements.reduce((acc, s) => acc + s.netPayoutKrw, 0)
function HeaderBar(): React.ReactElement {
return (
<>
{/* Header */}
<Box
sx={{
...panelSx,
@ -247,316 +34,247 @@ export default async function AdminAdsPage(): Promise<React.ReactElement> {
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
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)',
gap: 2
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: '10px',
bgcolor: 'rgba(56, 189, 248, 0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: C.cyanLight,
fontWeight: 800,
fontSize: '18px',
width: 4,
height: 32,
borderRadius: '999px',
background: `linear-gradient(180deg, ${C.orange} 0%, ${C.accent} 60%, ${C.purple} 100%)`
}}
>
P
</Box>
/>
<Box>
<Typography sx={{ fontFamily: FONT_SANS, fontWeight: 700, fontSize: '14px', color: C.bright }}>
Registered Publisher Account: yunchanpaca@gmail.com
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
Payout Beneficiary: D3RO Voice AI KB국민은행 928702-00-184920 120-88-01923
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<TactileBadge mono tone="success">KYC Verified</TactileBadge>
<TactileBadge mono tone="accent">3.3% </TactileBadge>
</Box>
</Box>
{/* KPI Cards */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' },
gap: 2,
}}
<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 }}
>
<DoubleBezelCard interactive>
<Box sx={{ p: 2.5 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
Est. Monthly Ad Revenue
Ad Mediation &amp; Monetization
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.cyanLight, my: 0.5 }}>
${totalGrossRevenue.toFixed(2)}
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.green400 }}>
{(totalGrossRevenue * 1350).toLocaleString()} ( 1,350)
<TactileBadge tone="warning" mono>
FAIL-CLOSED SANDBOX
</TactileBadge>
</Box>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, letterSpacing: '0.04em', mt: 0.25 }}>
Mediation waterfall roster, rewarded token grants, network integration state
</Typography>
</Box>
</DoubleBezelCard>
<DoubleBezelCard interactive>
<Box sx={{ p: 2.5 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
Weighted Avg. eCPM
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.bright, my: 0.5 }}>
${avgEcpm}
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight }}>
Floor: $2.00 min Max: $18.00
</Typography>
</Box>
</DoubleBezelCard>
<DoubleBezelCard interactive>
<Box sx={{ p: 2.5 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
Total Ad Impressions
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.bright, my: 0.5 }}>
{(totalImpressions / 1000).toFixed(1)}k
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
Avg. Fill Rate: 96.8%
</Typography>
</Box>
</DoubleBezelCard>
<DoubleBezelCard interactive>
<Box sx={{ p: 2.5 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
Net Payout Settled (KRW)
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.green400, my: 0.5 }}>
{(totalSettledKrw / 10000).toFixed(1)}
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.green400 }}>
{totalSettledKrw.toLocaleString()}
</Typography>
</Box>
</DoubleBezelCard>
</Box>
{/* 10+ Multi-Ad Network Mediation Matrix */}
<Box sx={{ ...panelSx, p: { xs: 2, md: 3 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
10+ Active Ad Networks & Header Bidding Matrix
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
First-price real-time bidding auction with sub-800ms SLA fallback to Direct House AI Sponsors.
</Typography>
</Box>
<TactileBadge mono tone="default">AUCTION TIMEOUT: 800MS</TactileBadge>
</Box>
<Box sx={{ overflowX: 'auto' }}>
<Box component="table" sx={tableSx}>
<thead>
<tr>
<th style={{ textAlign: 'left' }}>Demand Partner</th>
<th style={{ textAlign: 'left' }}>Adapter Protocol</th>
<th style={{ textAlign: 'left' }}>Primary Slot Format</th>
<th style={{ textAlign: 'right' }}>Impressions</th>
<th style={{ textAlign: 'right' }}>CTR</th>
<th style={{ textAlign: 'right' }}>Bid eCPM</th>
<th style={{ textAlign: 'right' }}>Gross Revenue</th>
<th style={{ textAlign: 'center' }}>Fill Rate</th>
<th style={{ textAlign: 'center' }}>Status</th>
</tr>
</thead>
<tbody>
{networks.map((net) => (
<tr key={net.id}>
<td style={{ fontWeight: 600, color: C.bright }}>{net.name}</td>
<td style={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight }}>{net.adapterType}</td>
<td style={{ color: C.dim, fontSize: '12px' }}>{net.format}</td>
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, color: C.bright }}>
{net.impressions.toLocaleString()}
</td>
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, color: C.green400 }}>{net.ctr}</td>
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, fontWeight: 700, color: C.cyanLight }}>
${net.ecpm.toFixed(2)}
</td>
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, fontWeight: 700, color: C.bright }}>
${net.grossRevenueUsd.toFixed(2)}
</td>
<td style={{ textAlign: 'center', fontFamily: FONT_MONO, color: C.dim }}>{net.fillRate}</td>
<td style={{ textAlign: 'center' }}>
<Box
component="span"
sx={{
...statusBadgeSx,
bgcolor:
net.status === 'active'
? 'rgba(34, 197, 94, 0.12)'
: 'rgba(56, 189, 248, 0.12)',
color: net.status === 'active' ? C.green400 : C.cyanLight,
borderColor:
net.status === 'active' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(56, 189, 248, 0.3)',
}}
>
{net.status.toUpperCase()}
</Box>
</td>
</tr>
))}
</tbody>
</Box>
</Box>
</Box>
{/* Monthly Settlement & Tax Withholding Payout Ledger */}
<Box sx={{ ...panelSx, p: { xs: 2, md: 3 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
Monthly Revenue Settlement & Payout Ledger
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
Net-30 / Net-60 cycle settlements with automatic 3.3% Korean withholding tax deduction.
</Typography>
</Box>
<TactileBadge mono tone="success">TAX WITHHOLDING AUTO-CALCULATED</TactileBadge>
</Box>
<Box sx={{ overflowX: 'auto' }}>
<Box component="table" sx={tableSx}>
<thead>
<tr>
<th style={{ textAlign: 'left' }}>Settlement ID</th>
<th style={{ textAlign: 'left' }}>Cycle Month</th>
<th style={{ textAlign: 'left' }}>Network Source</th>
<th style={{ textAlign: 'right' }}>Gross ($ USD)</th>
<th style={{ textAlign: 'center' }}>Withholding Tax</th>
<th style={{ textAlign: 'right' }}>Net Payout ( KRW)</th>
<th style={{ textAlign: 'left' }}>Beneficiary Method</th>
<th style={{ textAlign: 'center' }}>Payout Status</th>
</tr>
</thead>
<tbody>
{settlements.map((s) => (
<tr key={s.id}>
<td style={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight }}>{s.id}</td>
<td style={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>{s.cycleMonth}</td>
<td style={{ fontWeight: 600, color: C.bright }}>{s.networkName}</td>
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, color: C.bright }}>
${s.grossUsd.toFixed(2)}
</td>
<td style={{ textAlign: 'center', fontFamily: FONT_MONO, fontSize: '11px', color: C.orange400 }}>
{s.withholdingTax}
</td>
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, fontWeight: 700, color: C.green400 }}>
{s.netPayoutKrw.toLocaleString()}
</td>
<td style={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>{s.method}</td>
<td style={{ textAlign: 'center' }}>
<Box
component="span"
sx={{
...statusBadgeSx,
bgcolor:
s.payoutStatus === 'paid'
? 'rgba(34, 197, 94, 0.12)'
: 'rgba(234, 179, 8, 0.12)',
color: s.payoutStatus === 'paid' ? C.green400 : C.orange400,
borderColor:
s.payoutStatus === 'paid' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(234, 179, 8, 0.3)',
}}
>
{s.payoutStatus.toUpperCase()}
</Box>
</td>
</tr>
))}
</tbody>
</Box>
</Box>
</Box>
</>
)
}
function RewardKpis({ stats }: { stats: AdRewardStats }): React.ReactElement {
const cards = [
{
title: `Rewarded Claims (${stats.windowDays}d)`,
value: stats.totalClaims.toLocaleString(),
subtext: 'Verified rewarded-video completions'
},
{
title: `Tokens Granted (${stats.windowDays}d)`,
value: stats.totalRewardTokens.toLocaleString(),
subtext: 'Cloud AI tokens issued via ad rewards'
},
{
title: `Unique Claimants (${stats.windowDays}d)`,
value: stats.uniqueClaimants.toLocaleString(),
subtext: 'Distinct accounts that redeemed rewards'
},
{
title: 'Live Ad Networks',
value: `0 / ${MEDIATION_ROSTER.length}`,
subtext: 'All adapters fail-closed until SDK contracts land'
}
]
return (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' }, gap: 2 }}>
{cards.map((card) => (
<Box key={card.title} sx={{ ...panelSx, p: 2.5 }}>
<Typography
sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mb: 1, textTransform: 'uppercase', letterSpacing: '0.08em' }}
>
{card.title}
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '22px', fontWeight: 700, color: C.bright, lineHeight: 1.25 }}>
{card.value}
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>{card.subtext}</Typography>
</Box>
))}
</Box>
)
}
function MediationRosterPanel(): React.ReactElement {
return (
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1.5, mb: 1 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright }}>
Mediation Waterfall Roster
</Typography>
<Box sx={statusBadgeSx('orange')}>NO LIVE BIDS</Box>
</Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 2 }}>
. SDK/
fail-closed로 , .
</Typography>
<Box sx={{ overflowX: 'auto' }}>
<Box component="table" sx={tableSx}>
<Box component="thead">
<Box component="tr">
<Box component="th">Network</Box>
<Box component="th">Adapter ID</Box>
<Box component="th">Formats</Box>
<Box component="th">Floor eCPM</Box>
<Box component="th">Integration</Box>
</Box>
</Box>
<Box component="tbody">
{MEDIATION_ROSTER.map((network) => (
<Box component="tr" key={network.networkId}>
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.bright }}>
{network.name}
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
{network.networkId}
</Box>
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '12px' }}>
{network.formats.map((format) => FORMAT_LABEL[format] ?? format).join(' · ')}
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
${network.floorEcpm.toFixed(2)}
</Box>
<Box component="td">
<Box sx={statusBadgeSx('orange')}>NOT INTEGRATED</Box>
</Box>
</Box>
))}
</Box>
</Box>
</Box>
</DoubleBezelCard>
)
}
function RewardBreakdownPanel({ stats }: { stats: AdRewardStats }): React.ReactElement {
return (
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1fr 1.4fr' }, gap: 3 }}>
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright, mb: 2 }}>
Rewarded Grants by Network
</Typography>
{stats.byNetwork.length === 0 ? (
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>
{stats.windowDays} .
</Typography>
) : (
<Box sx={{ overflowX: 'auto' }}>
<Box component="table" sx={tableSx}>
<Box component="thead">
<Box component="tr">
<Box component="th">Network</Box>
<Box component="th">Claims</Box>
<Box component="th">Tokens</Box>
</Box>
</Box>
<Box component="tbody">
{stats.byNetwork.map((summary) => (
<Box component="tr" key={summary.network}>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
{summary.network}
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
{summary.claims.toLocaleString()}
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.green400 }}>
+{summary.rewardTokens.toLocaleString()}
</Box>
</Box>
))}
</Box>
</Box>
</Box>
)}
</DoubleBezelCard>
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright, mb: 2 }}>
Recent Verified Claims
</Typography>
{stats.recentClaims.length === 0 ? (
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}> .</Typography>
) : (
<Box sx={{ overflowX: 'auto' }}>
<Box component="table" sx={tableSx}>
<Box component="thead">
<Box component="tr">
<Box component="th">Verified At (UTC)</Box>
<Box component="th">Network</Box>
<Box component="th">Placement</Box>
<Box component="th">Tokens</Box>
</Box>
</Box>
<Box component="tbody">
{stats.recentClaims.map((claim) => (
<Box component="tr" key={claim.id}>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
{formatDateTime(claim.verifiedAt)}
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
{claim.network}
</Box>
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '12px' }}>{claim.placement}</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.green400 }}>
+{claim.rewardTokens.toLocaleString()}
</Box>
</Box>
))}
</Box>
</Box>
</Box>
)}
</DoubleBezelCard>
</Box>
)
}
function RewardsUnavailablePanel({ reason }: { reason: string }): React.ReactElement {
return (
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '14px', fontWeight: 600, color: C.orange400, mb: 1 }}>
Rewarded .
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim }}>{reason}</Typography>
</DoubleBezelCard>
)
}
export default async function AdminAdsPage(): Promise<React.ReactElement> {
await requireManager()
let stats: AdRewardStats | null = null
let statsError: string | null = null
if (!isSupabaseAdminConfigured()) {
statsError =
'SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 ad_reward_claims 데이터에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다.'
} else {
try {
stats = await fetchAdRewardStats()
} catch (error) {
statsError = error instanceof Error ? error.message : 'Unknown ad reward stats error'
}
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<HeaderBar />
{stats && <RewardKpis stats={stats} />}
<MediationRosterPanel />
{stats ? <RewardBreakdownPanel stats={stats} /> : <RewardsUnavailablePanel reason={statsError ?? ''} />}
</Box>
)
}

View file

@ -4,10 +4,13 @@
import { Box, Typography } from '@mui/material'
import { C, FONT_SANS, FONT_MONO, panelSx, statusBadgeSx } from '@/lib/console-theme'
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
import { getSupabaseServerClient } from '@/lib/supabase-server'
import { getSupabaseAdminClient, isSupabaseAdminConfigured } from '@/lib/supabase-admin'
import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel'
import { requireManager } from '@/lib/admin-guard'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { AuditDiffViewer } from '@/components/audit-diff-viewer'
import { sanitizeAuditSnapshot } from '@/lib/audit-sanitize'
interface PageProps {
params: Promise<{ id: string }>
@ -15,40 +18,36 @@ interface PageProps {
export default async function AuditLogDetailPage({ params }: PageProps): Promise<React.ReactElement> {
await requireManager()
const { id } = await params
const supabase = await getSupabaseServerClient()
const { data: log } = await supabase
.from('audit_log')
.select('*')
.eq('id', parseInt(id, 10))
.maybeSingle()
const typedLog = log || {
id: parseInt(id, 10) || 101,
admin_id: 'usr_d3ro_001',
action: 'MODEL_ENDPOINT_UPDATE',
target_type: 'model',
target_id: 'whisper-large-v3-turbo',
created_at: '2026-08-19T10:45:00Z',
memo: 'Enabled 6.2x turbo acceleration, updated model path to weights/large-v3-turbo.pt and tuned dual-condition parallel buffer flush threshold.',
before_data: {
model_id: 'whisper-large-v3',
acceleration: '1.0x',
latency_ms: 880,
buffer_flush: 'sequential',
is_default: true,
},
after_data: {
model_id: 'whisper-large-v3-turbo',
acceleration: '6.2x',
latency_ms: 142,
buffer_flush: 'parallel-dual-condition',
is_default: true,
},
if (!isSupabaseAdminConfigured()) {
return (
<UnavailableAdminPanel
title="Audit Log Detail"
capability="Admin actions, changes, accountability"
reason="SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 Supabase 관리 데이터에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다."
/>
)
}
const { id } = await params
const logId = Number.parseInt(id, 10)
if (!Number.isSafeInteger(logId) || logId <= 0) notFound()
const supabase = await getSupabaseAdminClient()
const adminName = 'D3RO System Administrator'
const { data: log, error: logError } = await supabase
.from('audit_log')
.select('id, admin_id, action, target_type, target_id, before_data, after_data, memo, created_at')
.eq('id', logId)
.maybeSingle()
if (logError) throw new Error(`Supabase audit detail failed: ${logError.message}`)
if (!log) notFound()
const typedLog = log
const { data: adminProfile, error: adminError } = await supabase
.from('profiles')
.select('name')
.eq('id', typedLog.admin_id)
.maybeSingle()
if (adminError) throw new Error(`Supabase audit actor failed: ${adminError.message}`)
const adminName = typeof adminProfile?.name === 'string' && adminProfile.name ? adminProfile.name : 'Unknown administrator'
return (
<>
@ -82,7 +81,7 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise
sx={{
fontFamily: FONT_SANS,
fontSize: '20px',
fontWeight: 700,
fontWeight: 500,
color: C.bright,
letterSpacing: '-0.02em',
m: 0,
@ -99,7 +98,7 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise
Back to Audit Ledger
</Link>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
SHA-256 Checksum Verified
Persisted audit record
</Typography>
</Box>
</Box>
@ -111,7 +110,7 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1fr 1fr' }, gap: 3 }}>
{/* Details Card */}
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright, mb: 2 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright, mb: 2 }}>
Transaction Metadata
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, fontFamily: FONT_SANS, fontSize: '13px' }}>
@ -125,7 +124,7 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise
{/* Memo Card */}
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright, mb: 2 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright, mb: 2 }}>
Administrative Intent & Reason
</Typography>
<Box
@ -148,7 +147,7 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise
{/* Visual JSON State Diff */}
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
Entity State Transition Diff (Before vs After)
</Typography>
<TactileBadge tone="success" mono>
@ -157,8 +156,8 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise
</Box>
<AuditDiffViewer
beforeData={typedLog.before_data as Record<string, unknown> | null}
afterData={typedLog.after_data as Record<string, unknown> | null}
beforeData={sanitizeAuditSnapshot(typedLog.before_data)}
afterData={sanitizeAuditSnapshot(typedLog.after_data)}
/>
</DoubleBezelCard>
</Box>

View file

@ -4,7 +4,8 @@
import { Box, Typography, Button } from '@mui/material'
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, filterBtnSx, statusBadgeSx } from '@/lib/console-theme'
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
import { getSupabaseServerClient } from '@/lib/supabase-server'
import { getSupabaseAdminClient, isSupabaseAdminConfigured } from '@/lib/supabase-admin'
import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel'
import { requireManager } from '@/lib/admin-guard'
import Link from 'next/link'
@ -14,36 +15,40 @@ interface PageProps {
export default async function AuditLogPage({ searchParams }: PageProps): Promise<React.ReactElement> {
await requireManager()
if (!isSupabaseAdminConfigured()) {
return (
<UnavailableAdminPanel
title="Audit Log"
capability="Admin actions, changes, accountability"
reason="SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 Supabase 관리 데이터에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다."
/>
)
}
const params = await searchParams
const targetTypeFilter = params.target_type ?? 'all'
const page = parseInt(params.page ?? '1', 10)
if (!['all', 'subscription', 'profile'].includes(targetTypeFilter)) {
throw new Error('Invalid audit target type filter')
}
const pageValue = params.page ?? '1'
if (!/^[1-9]\d{0,6}$/.test(pageValue)) throw new Error('Invalid audit page')
const page = Number(pageValue)
const limit = 20
const from = (page - 1) * limit
const to = from + limit - 1
const supabase = await getSupabaseServerClient()
const supabase = await getSupabaseAdminClient()
let query = supabase.from('audit_log').select('*', { count: 'exact' })
let query = supabase.from('audit_log').select('id, admin_id, action, target_type, target_id, memo, created_at', { count: 'exact' })
if (targetTypeFilter !== 'all') {
query = query.eq('target_type', targetTypeFilter)
}
const { data: rawLogs } = await query
const { data: rawLogs, error: logsError } = await query
.order('created_at', { ascending: false })
.range(from, to)
if (logsError) throw new Error(`Supabase audit log failed: ${logsError.message}`)
let logs = (rawLogs ?? []) as Array<Record<string, unknown>>
if (logs.length === 0) {
// Rich Mock Security Audit Logs
logs = [
{ id: 101, created_at: '2026-08-19T10:45:00Z', admin_id: 'usr_d3ro_001', admin_name: 'Admin User', action: 'MODEL_ENDPOINT_UPDATE', target_type: 'model', target_id: 'whisper-large-v3-turbo', memo: 'Enabled 6.2x turbo acceleration & parallel flush' },
{ id: 102, created_at: '2026-08-19T09:12:00Z', admin_id: 'usr_d3ro_001', admin_name: 'Admin User', action: 'SUBSCRIPTION_UPGRADE', target_type: 'subscription', target_id: 'usr_d3ro_002', memo: 'Upgraded Sarah Kim to PRO+ VIP tier' },
{ id: 103, created_at: '2026-08-18T16:30:00Z', admin_id: 'usr_d3ro_001', admin_name: 'Admin User', action: 'SECURITY_POLICY_CHECK', target_type: 'system', target_id: 'cors_whitelist', memo: 'Verified CORS allowlist for desktop/web clients' },
{ id: 104, created_at: '2026-08-18T14:15:00Z', admin_id: 'usr_d3ro_001', admin_name: 'Admin User', action: 'VECTOR_INDEX_REBUILD', target_type: 'vector_rag', target_id: 'sqlite_vec_01', memo: 'Reindexed 4,820 documents with nomic-embed-text' },
{ id: 105, created_at: '2026-08-17T11:00:00Z', admin_id: 'usr_d3ro_001', admin_name: 'Admin User', action: 'DIARIZATION_THRESHOLD_SET', target_type: 'pipeline', target_id: 'pyannote_3.1', memo: 'Adjusted speaker similarity clustering threshold to 0.72' },
]
}
const logs = (rawLogs ?? []) as Array<Record<string, unknown>>
return (
<>
@ -77,7 +82,7 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
sx={{
fontFamily: FONT_SANS,
fontSize: '20px',
fontWeight: 700,
fontWeight: 500,
color: C.bright,
letterSpacing: '-0.02em',
m: 0,
@ -86,7 +91,7 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
Security Audit Log & Event Ledger
</Typography>
<TactileBadge tone="mono" mono>
IMMUTABLE AUDIT TRAIL
PERSISTED AUDIT TRAIL
</TactileBadge>
</Box>
<Typography
@ -98,13 +103,13 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
mt: 0.25,
}}
>
ADMIN ACTION AUDIT TIER MODIFICATIONS ENDPOINT CONFIGURATION TRACE
Admin action audit, role and subscription modifications
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
{['all', 'subscription', 'profile', 'model', 'system'].map((t) => (
{['all', 'subscription', 'profile'].map((t) => (
<Link key={t} href={`/audit-log?target_type=${t}`} style={{ textDecoration: 'none' }}>
<Box sx={filterBtnSx(targetTypeFilter === t)}>
{t.toUpperCase()}
@ -117,11 +122,11 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
{/* Main Table Card */}
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
Chronological Security Log Entries
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
AUTO-SIGN SHA-256 VERIFIED
DATABASE AUDIT RECORDS
</Typography>
</Box>
@ -138,7 +143,13 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
</Box>
</Box>
<Box component="tbody">
{logs.map((log) => (
{logs.length === 0 ? (
<Box component="tr">
<Box component="td" colSpan={6} sx={{ textAlign: 'center', color: C.dim, py: 4 }}>
No audit events match the selected filters.
</Box>
</Box>
) : logs.map((log) => (
<Box component="tr" key={log.id as number}>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
{new Date(log.created_at as string).toLocaleString()}

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
}): Promise<React.ReactElement> {
await requireManager()
const admin = await requireManager()
return (
<Box
@ -27,7 +27,7 @@ export default async function AdminLayout({
zIndex: 1,
}}
>
<AdminSidebar />
<AdminSidebar identity={{ email: admin.email ?? admin.id, role: admin.role }} />
<Box
component="main"
sx={{
@ -56,4 +56,3 @@ export default async function AdminLayout({
</Box>
)
}

View file

@ -24,18 +24,22 @@ import {
} from '@mui/material'
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx, primaryButtonSx } from '@/lib/console-theme'
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
import {
fetchModelEndpoints,
fetchSttEndpoints,
createSttEndpoint,
updateSttEndpoint,
deleteSttEndpoint,
setDefaultSttEndpoint,
testSttEndpoint,
type ModelEndpoint,
type SttProviderEndpoint,
type STTProviderCategory,
import type {
ModelEndpoint,
SttProviderEndpoint,
STTProviderCategory,
} from '@/lib/api-server'
import {
createModelEndpointClient,
createSttEndpointClient,
deleteModelEndpointClient,
deleteSttEndpointClient,
fetchModelEndpointsClient,
fetchSttEndpointsClient,
setDefaultSttEndpointClient,
testSttEndpointClient,
updateSttEndpointClient,
} from '@/lib/backend-admin-client'
const PRESET_LLM_MODELS = [
{ modelId: 'whisper-large-v3-turbo', modelName: 'Faster-Whisper Large-v3 Turbo (Local)', provider: 'Local Sidecar', endpointUrl: 'http://localhost:8971/stt/transcribe', promptCost: '0.000000', completionCost: '0.000000' },
@ -50,82 +54,19 @@ const PRESET_STT_PROVIDERS: Array<{
providerType: STTProviderCategory
endpointUrl: string
modelId: string
method: string
method: 'multipart' | 'binary-stream' | 'json-base64' | 'custom-rest'
costPerMinute: number
language: string
prompt?: string
description: string
}> = [
{
name: 'Groq Whisper LPU Turbo (Ultra Fast)',
providerType: 'groq',
endpointUrl: 'https://api.groq.com/openai/v1/audio/transcriptions',
modelId: 'whisper-large-v3-turbo',
method: 'multipart',
costPerMinute: 0.0005,
language: 'ko',
description: 'LPU 가속 기반 초저지연(~140ms) 고속 전사, 극저비용',
},
{
name: 'OpenAI Whisper Official',
providerType: 'openai',
endpointUrl: 'https://api.openai.com/v1/audio/transcriptions',
modelId: 'whisper-1',
method: 'multipart',
costPerMinute: 0.006,
language: 'ko',
description: 'OpenAI 공식 Whisper-1 모델, 표준 고품질 다국어 인식',
},
{
name: 'Deepgram Nova-3 Industry Standard',
providerType: 'deepgram',
endpointUrl: 'https://api.deepgram.com/v1/listen',
modelId: 'nova-3',
method: 'binary-stream',
costPerMinute: 0.0043,
language: 'ko',
description: 'Nova-3 스마트 구두점, 실시간 스트리밍 최적화 및 고정밀 전사',
},
{
name: 'Google Gemini 2.0 Flash / Cloud STT',
providerType: 'google',
endpointUrl: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent',
modelId: 'gemini-2.0-flash',
method: 'json-base64',
costPerMinute: 0.001,
language: 'ko',
description: 'Gemini 2.0 Flash 기반 다국어 및 문맥 인식 오디오 전사',
},
{
name: 'AssemblyAI Universal-2',
providerType: 'assemblyai',
endpointUrl: 'https://api.assemblyai.com/v2/transcript',
modelId: 'best',
method: 'multipart',
costPerMinute: 0.0025,
language: 'ko',
description: '문맥 인식 음향 모델 및 자동 단락 구분 STT',
},
{
name: 'Microsoft Azure Speech Service',
providerType: 'azure',
endpointUrl: 'https://koreacentral.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1',
modelId: 'azure-speech',
method: 'binary-stream',
costPerMinute: 0.005,
language: 'ko-KR',
description: 'Azure Cognitive Speech API 엔터프라이즈 음성 인식',
},
{
name: 'Self-Hosted / Local Faster-Whisper Sidecar',
providerType: 'local-sidecar',
endpointUrl: 'http://localhost:8971/stt/transcribe',
modelId: 'whisper-large-v3-turbo',
method: 'multipart',
costPerMinute: 0.0,
language: 'ko',
description: '사내 프라이빗 서버 또는 로컬 Whisper 사이드카 (완전 무료/오프라인)',
},
{ name: 'Groq Whisper LPU Turbo (Ultra Fast)', providerType: 'groq', endpointUrl: 'https://api.groq.com/openai/v1/audio/transcriptions', modelId: 'whisper-large-v3-turbo', method: 'multipart', costPerMinute: 0.0005, language: 'ko', description: 'LPU 가속 기반 초저지연(~140ms) 고속 전사, 극저비용' },
{ name: 'OpenAI Whisper Official', providerType: 'openai', endpointUrl: 'https://api.openai.com/v1/audio/transcriptions', modelId: 'whisper-1', method: 'multipart', costPerMinute: 0.006, language: 'ko', description: 'OpenAI 공식 Whisper-1 모델, 표준 고품질 다국어 인식' },
{ name: 'Deepgram Nova-3 Industry Standard', providerType: 'deepgram', endpointUrl: 'https://api.deepgram.com/v1/listen', modelId: 'nova-3', method: 'binary-stream', costPerMinute: 0.0043, language: 'ko', description: 'Nova-3 스마트 구두점, 실시간 스트리밍 최적화 및 고정밀 전사' },
{ name: 'Google Gemini 2.0 Flash / Cloud STT', providerType: 'google', endpointUrl: 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent', modelId: 'gemini-2.0-flash', method: 'json-base64', costPerMinute: 0.001, language: 'ko', description: 'Gemini 2.0 Flash 기반 다국어 및 문맥 인식 오디오 전사' },
{ name: 'AssemblyAI Universal-2', providerType: 'assemblyai', endpointUrl: 'https://api.assemblyai.com/v2/transcript', modelId: 'best', method: 'multipart', costPerMinute: 0.0025, language: 'ko', description: '문맥 인식 음향 모델 및 자동 단락 구분 STT' },
{ name: 'Microsoft Azure Speech Service', providerType: 'azure', endpointUrl: 'https://koreacentral.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1', modelId: 'azure-speech', method: 'binary-stream', costPerMinute: 0.005, language: 'ko-KR', description: 'Azure Cognitive Speech API 엔터프라이즈 음성 인식' },
{ name: 'Self-Hosted / Local Faster-Whisper Sidecar', providerType: 'local-sidecar', endpointUrl: 'http://localhost:8971/stt/transcribe', modelId: 'whisper-large-v3-turbo', method: 'multipart', costPerMinute: 0.0, language: 'ko', description: '사내 프라이빗 서버 또는 로컬 Whisper 사이드카 (완전 무료/오프라인)' },
]
export default function ServiceModelsPage(): React.ReactElement {
@ -142,39 +83,42 @@ export default function ServiceModelsPage(): React.ReactElement {
// STT Form State
const [sttName, setSttName] = useState('')
const [sttProviderType, setSttProviderType] = useState<STTProviderCategory>('groq')
const [sttProviderType, setSttProviderType] = useState<STTProviderCategory>('custom')
const [sttEndpointUrl, setSttEndpointUrl] = useState('')
const [sttApiKey, setSttApiKey] = useState('')
const [sttModelId, setSttModelId] = useState('whisper-large-v3-turbo')
const [sttModelId, setSttModelId] = useState('')
const [sttMethod, setSttMethod] = useState<'multipart' | 'binary-stream' | 'json-base64' | 'custom-rest'>('multipart')
const [sttLanguage, setSttLanguage] = useState('ko')
const [sttPrompt, setSttPrompt] = useState('')
const [sttCostPerMinute, setSttCostPerMinute] = useState('0.000500')
const [sttCostPerMinute, setSttCostPerMinute] = useState('0')
const [sttFallbackPriority, setSttFallbackPriority] = useState(1)
const [sttIsDefault, setSttIsDefault] = useState(false)
const [sttMemo, setSttMemo] = useState('')
// LLM Endpoints State
const [llmEndpoints, setLlmEndpoints] = useState<ModelEndpoint[]>([])
const [llmModalOpen, setLlmModalOpen] = useState(false)
const [llmLoading, setLlmLoading] = useState(false)
const [llmPingStatus, setLlmPingStatus] = useState<Record<string, string>>({})
const [dataError, setDataError] = useState<string | null>(null)
// LLM Form State
const [llmModelId, setLlmModelId] = useState('')
const [llmModelName, setLlmModelName] = useState('')
const [llmProvider, setLlmProvider] = useState<string>('OpenAI')
const [llmProvider, setLlmProvider] = useState<string>('Custom')
const [llmEndpointUrl, setLlmEndpointUrl] = useState('')
const [llmApiKey, setLlmApiKey] = useState('')
const [llmPromptCost, setLlmPromptCost] = useState('0.000150')
const [llmCompletionCost, setLlmCompletionCost] = useState('0.000600')
const [llmPromptCost, setLlmPromptCost] = useState('0')
const [llmCompletionCost, setLlmCompletionCost] = useState('0')
const [llmMemo, setLlmMemo] = useState('')
const loadData = async () => {
setDataError(null)
try {
const [sttData, llmData] = await Promise.all([fetchSttEndpoints(), fetchModelEndpoints()])
const [sttData, llmData] = await Promise.all([fetchSttEndpointsClient(), fetchModelEndpointsClient()])
setSttEndpoints(sttData)
setLlmEndpoints(llmData)
} catch {
// Fallbacks handled in fetch functions
} catch (error) {
setDataError(error instanceof Error ? error.message : '관리자 백엔드에서 엔드포인트를 불러오지 못했습니다.')
}
}
@ -184,34 +128,21 @@ export default function ServiceModelsPage(): React.ReactElement {
// ── STT Handlers ──────────────────────────────────────────────────────────
const applySttPreset = (presetName: string) => {
const p = PRESET_STT_PROVIDERS.find((x) => x.name === presetName)
if (!p) return
setSttName(p.name)
setSttProviderType(p.providerType)
setSttEndpointUrl(p.endpointUrl)
setSttModelId(p.modelId)
setSttMethod(p.method as SttProviderEndpoint['method'])
setSttCostPerMinute(p.costPerMinute.toFixed(6))
setSttLanguage(p.language)
if (p.prompt) setSttPrompt(p.prompt)
setSttTestModalResult(null)
}
const openAddSttModal = () => {
setSttEditingId(null)
setSttName('')
setSttProviderType('groq')
setSttEndpointUrl('https://api.groq.com/openai/v1/audio/transcriptions')
setSttProviderType('custom')
setSttEndpointUrl('')
setSttApiKey('')
setSttModelId('whisper-large-v3-turbo')
setSttModelId('')
setSttMethod('multipart')
setSttLanguage('ko')
setSttPrompt('')
setSttCostPerMinute('0.000500')
setSttCostPerMinute('0')
setSttFallbackPriority(sttEndpoints.length + 1)
setSttIsDefault(sttEndpoints.length === 0)
setSttTestModalResult(null)
setSttMemo('')
setSttModalOpen(true)
}
@ -252,12 +183,13 @@ export default function ServiceModelsPage(): React.ReactElement {
isDefault: sttIsDefault,
isActive: true,
fallbackPriority: sttFallbackPriority,
memo: sttMemo.trim(),
}
if (sttEditingId) {
await updateSttEndpoint(sttEditingId, payload)
await updateSttEndpointClient(sttEditingId, payload)
} else {
await createSttEndpoint(payload)
await createSttEndpointClient(payload)
}
await loadData()
@ -270,8 +202,10 @@ export default function ServiceModelsPage(): React.ReactElement {
}
const handleSetDefaultStt = async (id: number) => {
const memo = prompt('기본 STT 공급자 변경 사유를 입력하세요.')?.trim() ?? ''
if (memo.length < 3) return
try {
await setDefaultSttEndpoint(id)
await setDefaultSttEndpointClient(id, memo)
setSttEndpoints((prev) =>
prev.map((ep) => ({
...ep,
@ -285,11 +219,17 @@ export default function ServiceModelsPage(): React.ReactElement {
const handlePingStt = async (id: number) => {
setSttPingStatus((prev) => ({ ...prev, [id]: 'Testing...' }))
const result = await testSttEndpoint(id)
if (result.success) {
setSttPingStatus((prev) => ({ ...prev, [id]: `⚡ OK • ${result.latencyMs}ms` }))
} else {
setSttPingStatus((prev) => ({ ...prev, [id]: `❌ Failed` }))
try {
const result = await testSttEndpointClient(id)
setSttPingStatus((prev) => ({
...prev,
[id]: result.success ? `⚡ OK • ${result.latencyMs}ms` : `${result.message}`
}))
} catch (error) {
setSttPingStatus((prev) => ({
...prev,
[id]: `${error instanceof Error ? error.message : 'Test failed'}`
}))
}
}
@ -297,7 +237,7 @@ export default function ServiceModelsPage(): React.ReactElement {
setTestingInModal(true)
setSttTestModalResult(null)
try {
const result = await testSttEndpoint(sttEditingId ?? 0, sttApiKey, sttEndpointUrl)
const result = await testSttEndpointClient(sttEditingId ?? 0, sttApiKey, sttEndpointUrl)
setSttTestModalResult({
success: result.success,
message: result.message,
@ -316,8 +256,10 @@ export default function ServiceModelsPage(): React.ReactElement {
const handleDeleteStt = async (id: number) => {
if (!confirm('이 STT 프로바이더 엔드포인트를 삭제하시겠습니까?')) return
const memo = prompt('삭제 사유를 입력하세요.')?.trim() ?? ''
if (memo.length < 3) return
try {
await deleteSttEndpoint(id)
await deleteSttEndpointClient(id, memo)
setSttEndpoints((prev) => prev.filter((ep) => ep.id !== id))
} catch (err) {
alert('Error: ' + (err instanceof Error ? err.message : String(err)))
@ -326,15 +268,16 @@ export default function ServiceModelsPage(): React.ReactElement {
// ── LLM Handlers ──────────────────────────────────────────────────────────
const applyLlmPreset = (presetKey: string) => {
const p = PRESET_LLM_MODELS.find((m) => m.modelId === presetKey)
if (!p) return
setLlmModelId(p.modelId)
setLlmModelName(p.modelName)
setLlmProvider(p.provider)
setLlmEndpointUrl(p.endpointUrl)
setLlmPromptCost(p.promptCost)
setLlmCompletionCost(p.completionCost)
const openAddLlmModal = () => {
setLlmModelId('')
setLlmModelName('')
setLlmProvider('Custom')
setLlmEndpointUrl('')
setLlmApiKey('')
setLlmPromptCost('0')
setLlmCompletionCost('0')
setLlmMemo('')
setLlmModalOpen(true)
}
const handleAddLlmEndpoint = async (e: React.FormEvent) => {
@ -342,26 +285,23 @@ export default function ServiceModelsPage(): React.ReactElement {
setLlmLoading(true)
try {
const newEp: ModelEndpoint = {
id: Date.now(),
await createModelEndpointClient({
modelId: llmModelId,
modelName: llmModelName,
provider: llmProvider as ModelEndpoint['provider'],
provider: llmProvider,
endpointUrl: llmEndpointUrl,
apiKey: llmApiKey ? '••••••••' : '',
apiKey: llmApiKey,
costPer1kPromptTokens: parseFloat(llmPromptCost),
costPer1kCompletionTokens: parseFloat(llmCompletionCost),
latencyMs: 150,
isActive: true,
isDefault: false,
createdAt: new Date().toISOString(),
}
setLlmEndpoints((prev) => [...prev, newEp])
memo: llmMemo.trim(),
})
await loadData()
setLlmModalOpen(false)
setLlmModelId('')
setLlmModelName('')
setLlmEndpointUrl('')
setLlmApiKey('')
setLlmMemo('')
} catch (err) {
alert('Error: ' + (err instanceof Error ? err.message : String(err)))
} finally {
@ -369,12 +309,16 @@ export default function ServiceModelsPage(): React.ReactElement {
}
}
const handlePingLlm = (_id: number, modelKey: string) => {
setLlmPingStatus((prev) => ({ ...prev, [modelKey]: 'Testing...' }))
setTimeout(() => {
const lat = Math.floor(Math.random() * 80 + 40)
setLlmPingStatus((prev) => ({ ...prev, [modelKey]: `OK • ${lat}ms` }))
}, 500)
const handleDeleteLlm = async (id: number) => {
if (!confirm('이 LLM 엔드포인트를 삭제하시겠습니까?')) return
const memo = prompt('삭제 사유를 입력하세요.')?.trim() ?? ''
if (memo.length < 3) return
try {
await deleteModelEndpointClient(id, memo)
setLlmEndpoints((current) => current.filter((endpoint) => endpoint.id !== id))
} catch (error) {
alert('Error: ' + (error instanceof Error ? error.message : String(error)))
}
}
const defaultStt = sttEndpoints.find((e) => e.isDefault) || sttEndpoints[0]
@ -411,7 +355,7 @@ export default function ServiceModelsPage(): React.ReactElement {
sx={{
fontFamily: FONT_SANS,
fontSize: '20px',
fontWeight: 700,
fontWeight: 500,
color: C.bright,
letterSpacing: '-0.02em',
m: 0,
@ -432,7 +376,7 @@ export default function ServiceModelsPage(): React.ReactElement {
mt: 0.25,
}}
>
DYNAMIC ROUTING AUTO FAILOVER TOKEN & PER-MINUTE BILLING INSTANT DEFAULT SWITCH
Dynamic routing, auto failover, token and per-minute billing, instant default switch
</Typography>
</Box>
</Box>
@ -443,13 +387,19 @@ export default function ServiceModelsPage(): React.ReactElement {
+ Add STT Provider Endpoint
</Button>
) : (
<Button variant="contained" onClick={() => setLlmModalOpen(true)} sx={primaryButtonSx}>
<Button variant="contained" onClick={openAddLlmModal} sx={primaryButtonSx}>
+ Add LLM Model Endpoint
</Button>
)}
</Box>
</Box>
{dataError && (
<Alert severity="error" sx={{ mb: 3 }}>
{dataError}
</Alert>
)}
{/* Tabs Navigation */}
<Box sx={{ borderBottom: `1px solid ${C.border}`, mb: 3 }}>
<Tabs
@ -469,7 +419,7 @@ export default function ServiceModelsPage(): React.ReactElement {
sx={{
fontFamily: FONT_SANS,
fontSize: '14px',
fontWeight: 700,
fontWeight: 500,
color: activeTab === 'stt' ? C.bright : C.dim,
textTransform: 'none',
px: 3,
@ -483,7 +433,7 @@ export default function ServiceModelsPage(): React.ReactElement {
sx={{
fontFamily: FONT_SANS,
fontSize: '14px',
fontWeight: 700,
fontWeight: 500,
color: activeTab === 'llm' ? C.bright : C.dim,
textTransform: 'none',
px: 3,
@ -519,7 +469,7 @@ export default function ServiceModelsPage(): React.ReactElement {
</Box>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 500, color: C.bright }}>
Active Default Cloud STT: {defaultStt.name}
</Typography>
<Box component="span" sx={statusBadgeSx('green')}>
@ -545,7 +495,7 @@ export default function ServiceModelsPage(): React.ReactElement {
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
<Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
Configured Cloud Transcription Providers
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim }}>
@ -581,7 +531,7 @@ export default function ServiceModelsPage(): React.ReactElement {
) : (
sttEndpoints.map((ep) => (
<Box component="tr" key={ep.id} sx={{ bgcolor: ep.isDefault ? 'rgba(59, 130, 246, 0.05)' : 'transparent' }}>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight, fontWeight: 700 }}>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight, fontWeight: 500 }}>
#{ep.fallbackPriority}
</Box>
<Box component="td" sx={{ fontWeight: 600, color: C.bright }}>
@ -606,7 +556,7 @@ export default function ServiceModelsPage(): React.ReactElement {
</Box>
<Box component="td">
{ep.isDefault ? (
<Box component="span" sx={{ px: 1, py: 0.3, borderRadius: '4px', bgcolor: 'rgba(34, 197, 94, 0.2)', color: C.green400, fontSize: '10px', fontWeight: 700 }}>
<Box component="span" sx={{ px: 1, py: 0.3, borderRadius: '4px', bgcolor: 'rgba(34, 197, 94, 0.2)', color: C.green400, fontSize: '10px', fontWeight: 500 }}>
DEFAULT
</Box>
) : (
@ -691,7 +641,7 @@ export default function ServiceModelsPage(): React.ReactElement {
{activeTab === 'llm' && (
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
Active AI Reasoning & Action Endpoints
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
@ -757,8 +707,8 @@ export default function ServiceModelsPage(): React.ReactElement {
<Box sx={{ display: 'inline-flex', gap: 1 }}>
<Button
size="small"
variant="outlined"
onClick={() => handlePingLlm(ep.id, ep.modelId)}
color="error"
onClick={() => handleDeleteLlm(ep.id)}
sx={{
fontFamily: FONT_MONO,
fontSize: '10px',
@ -769,7 +719,7 @@ export default function ServiceModelsPage(): React.ReactElement {
'&:hover': { bgcolor: 'rgba(59, 130, 246, 0.15)', borderColor: C.accentLight },
}}
>
{llmPingStatus[ep.modelId] || '⚡ Ping'}
Delete
</Button>
</Box>
</Box>
@ -798,35 +748,44 @@ export default function ServiceModelsPage(): React.ReactElement {
},
}}
>
<DialogTitle sx={{ fontFamily: FONT_SANS, fontWeight: 700, color: C.bright, borderBottom: `1px solid ${C.border}`, px: 3, py: 2.5 }}>
<DialogTitle sx={{ fontFamily: FONT_SANS, fontWeight: 500, color: C.bright, borderBottom: `1px solid ${C.border}`, px: 3, py: 2.5 }}>
{sttEditingId ? 'Edit STT Provider Endpoint' : 'Add Cloud STT Provider Endpoint'}
</DialogTitle>
<Box component="form" onSubmit={handleSaveSttEndpoint}>
<DialogContent sx={{ px: 3, py: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* Quick Preset Selector */}
{sttEditingId === null && (
<FormControl fullWidth size="small">
<InputLabel sx={{ color: C.dim, fontFamily: FONT_SANS }}>Load Preset Template</InputLabel>
<InputLabel sx={{ color: C.dim }}> Load Preset Template (optional)</InputLabel>
<Select
label="Load Preset Template"
defaultValue=""
onChange={(e) => applySttPreset(e.target.value)}
value=""
label="⚡ Load Preset Template (optional)"
onChange={(e) => {
const preset = PRESET_STT_PROVIDERS.find((p) => p.name === e.target.value)
if (!preset) return
setSttName(preset.name)
setSttProviderType(preset.providerType)
setSttEndpointUrl(preset.endpointUrl)
setSttModelId(preset.modelId)
setSttMethod(preset.method)
setSttLanguage(preset.language)
setSttCostPerMinute(String(preset.costPerMinute))
if (preset.prompt) setSttPrompt(preset.prompt)
}}
sx={{
bgcolor: 'rgba(10, 17, 31, 0.7)',
color: C.bright,
borderRadius: '10px',
fontFamily: FONT_SANS,
'& fieldset': { borderColor: C.border },
'&:hover fieldset': { borderColor: C.borderHl },
}}
>
{PRESET_STT_PROVIDERS.map((p) => (
<MenuItem key={p.name} value={p.name} sx={{ fontFamily: FONT_SANS, fontSize: '13px' }}>
{p.name} ({p.providerType.toUpperCase()}) ${p.costPerMinute}/min
{PRESET_STT_PROVIDERS.map((preset) => (
<MenuItem key={preset.name} value={preset.name}>
{preset.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1.2fr 0.8fr' }, gap: 2 }}>
<TextField
label="Provider Name"
@ -984,6 +943,16 @@ export default function ServiceModelsPage(): React.ReactElement {
/>
</Box>
<TextField
label="Audit memo"
value={sttMemo}
onChange={(event) => setSttMemo(event.target.value)}
required
helperText="변경 사유를 3자 이상 입력하세요."
size="small"
sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'rgba(10, 17, 31, 0.7)', color: C.bright, borderRadius: '10px' } }}
/>
{/* Test Connection Inside Modal */}
<Box sx={{ p: 2, borderRadius: '12px', bgcolor: 'rgba(10, 17, 31, 0.6)', border: `1px solid ${C.border}`, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
@ -1029,7 +998,7 @@ export default function ServiceModelsPage(): React.ReactElement {
<Button onClick={() => setSttModalOpen(false)} sx={{ color: C.dim, textTransform: 'none' }}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={sttLoading} sx={primaryButtonSx}>
<Button type="submit" variant="contained" disabled={sttLoading || sttMemo.trim().length < 3} sx={primaryButtonSx}>
{sttLoading ? 'Saving...' : 'Save STT Provider'}
</Button>
</DialogActions>
@ -1052,33 +1021,40 @@ export default function ServiceModelsPage(): React.ReactElement {
},
}}
>
<DialogTitle sx={{ fontFamily: FONT_SANS, fontWeight: 700, color: C.bright, borderBottom: `1px solid ${C.border}`, px: 3, py: 2.5 }}>
<DialogTitle sx={{ fontFamily: FONT_SANS, fontWeight: 500, color: C.bright, borderBottom: `1px solid ${C.border}`, px: 3, py: 2.5 }}>
Add Reasoning Model Endpoint
</DialogTitle>
<Box component="form" onSubmit={handleAddLlmEndpoint}>
<DialogContent sx={{ px: 3, py: 3, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<FormControl fullWidth size="small">
<InputLabel sx={{ color: C.dim, fontFamily: FONT_SANS }}>Load Preset Template</InputLabel>
<InputLabel sx={{ color: C.dim }}> Load Preset Template (optional)</InputLabel>
<Select
label="Load Preset Template"
defaultValue=""
onChange={(e) => applyLlmPreset(e.target.value)}
value=""
label="⚡ Load Preset Template (optional)"
onChange={(e) => {
const preset = PRESET_LLM_MODELS.find((p) => p.modelId === e.target.value)
if (!preset) return
setLlmModelId(preset.modelId)
setLlmModelName(preset.modelName)
setLlmProvider(preset.provider)
setLlmEndpointUrl(preset.endpointUrl)
setLlmPromptCost(preset.promptCost)
setLlmCompletionCost(preset.completionCost)
}}
sx={{
bgcolor: 'rgba(10, 17, 31, 0.7)',
color: C.bright,
borderRadius: '10px',
fontFamily: FONT_SANS,
'& fieldset': { borderColor: C.border },
}}
>
{PRESET_LLM_MODELS.map((p) => (
<MenuItem key={p.modelId} value={p.modelId} sx={{ fontFamily: FONT_SANS, fontSize: '13px' }}>
{p.modelName} ({p.provider})
{PRESET_LLM_MODELS.map((preset) => (
<MenuItem key={preset.modelId} value={preset.modelId}>
{preset.modelName}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="Model ID"
value={llmModelId}
@ -1152,12 +1128,21 @@ export default function ServiceModelsPage(): React.ReactElement {
sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'rgba(10, 17, 31, 0.7)', color: C.bright, borderRadius: '10px', fontFamily: FONT_MONO } }}
/>
</Box>
<TextField
label="Audit memo"
value={llmMemo}
onChange={(event) => setLlmMemo(event.target.value)}
required
helperText="생성 사유를 3자 이상 입력하세요."
size="small"
sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'rgba(10, 17, 31, 0.7)', color: C.bright, borderRadius: '10px' } }}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 3, pt: 1, borderTop: `1px solid ${C.border}` }}>
<Button onClick={() => setLlmModalOpen(false)} sx={{ color: C.dim, textTransform: 'none' }}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={llmLoading} sx={primaryButtonSx}>
<Button type="submit" variant="contained" disabled={llmLoading || llmMemo.trim().length < 3} sx={primaryButtonSx}>
{llmLoading ? 'Saving...' : 'Save Endpoint'}
</Button>
</DialogActions>

View file

@ -3,21 +3,61 @@
import { Box, Typography, Button } from '@mui/material'
import { fetchServerStats } from '@/lib/api-server'
import { isSupabaseAdminConfigured } from '@/lib/supabase-admin'
import { fetchSubscriptionRevenue, type SubscriptionRevenue } from '@/lib/subscription-metrics'
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
import { DoubleBezelCard, StatRing, TactileBadge } from '@d3ro/ui/components/ds'
import { DashboardSimulator } from '@/components/dashboard-simulator'
import Link from 'next/link'
export default async function AdminOverviewPage(): Promise<React.ReactElement> {
const stats = await fetchServerStats()
const nonOperationalNodes = stats.nodes.filter((node) => node.status !== 'operational')
let revenue: SubscriptionRevenue | null = null
if (isSupabaseAdminConfigured()) {
try {
revenue = await fetchSubscriptionRevenue()
} catch {
revenue = null
}
}
const revenueCards = revenue
? [
{
title: 'Annual Recurring Revenue',
value: `$${revenue.arrUsd.toLocaleString()}`,
subtext: 'Active subscriptions × 12 months',
color: 'green' as const,
badge: 'ARR',
badgeColor: 'green' as const,
},
{
title: 'Monthly Recurring Revenue',
value: `$${revenue.mrrUsd.toLocaleString()}`,
subtext: `Pro ${revenue.tierBreakdown.pro.toLocaleString()} · Pro+ ${revenue.tierBreakdown.pro_plus.toLocaleString()}`,
color: 'blue' as const,
badge: 'MRR',
badgeColor: 'blue' as const,
},
{
title: 'Active Subscriptions',
value: revenue.activeCount.toLocaleString(),
subtext: 'Supabase subscriptions with status = active',
color: 'purple' as const,
badge: 'BILLING',
badgeColor: 'purple' as const,
},
]
: []
const bentoCards = [
{
title: 'Annual Recurring Revenue (ARR)',
value: `$${stats.arrUsd.toLocaleString()}`,
subtext: `MRR: $${stats.mrrUsd.toLocaleString()} • +18.4% MoM Growth`,
title: 'Backend Uptime',
value: `${Math.floor(stats.serverUptimeSeconds / 3600).toLocaleString()}h`,
subtext: 'Measured by the active .NET API process',
color: 'purple' as const,
badge: 'REVENUE',
badge: 'RUNTIME',
badgeColor: 'purple' as const,
icon: (
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -26,11 +66,11 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
),
},
{
title: 'Active Voice & Meeting Sessions',
title: 'Active Admin Accounts Today',
value: `${stats.activeUsersToday.toLocaleString()} Active`,
subtext: `${stats.totalUsers.toLocaleString()} Total Users • 18 Realtime Streams`,
subtext: `${stats.totalUsers.toLocaleString()} administrator accounts`,
color: 'blue' as const,
badge: 'VOICE STREAMS',
badge: 'AUTH',
badgeColor: 'blue' as const,
icon: (
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -52,11 +92,11 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
),
},
{
title: 'Speaker Diarization Accuracy',
value: `${stats.pipelines.meetingIntelligence.speakerAccuracyPercent}%`,
subtext: `${stats.pipelines.meetingIntelligence.templatesGeneratedToday} Meeting Docs • 42 Mindmaps`,
title: 'Recorded Backend Errors',
value: stats.errorCount.toLocaleString(),
subtext: 'Persisted server error log entries',
color: 'orange' as const,
badge: 'PHASE 15.5',
badge: 'ERROR LOG',
badgeColor: 'orange' as const,
icon: (
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -98,7 +138,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
sx={{
fontFamily: FONT_SANS,
fontSize: '20px',
fontWeight: 700,
fontWeight: 500,
color: C.bright,
letterSpacing: '-0.02em',
m: 0,
@ -107,7 +147,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
Unified Dashboard Overview
</Typography>
<TactileBadge ledColor="green" ledPulse tone="success" mono>
ONLINE v0.2.1-alpha
BACKEND CONNECTED
</TactileBadge>
</Box>
<Typography
@ -119,7 +159,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
mt: 0.25,
}}
>
REALTIME AI TELEMETRY ARR & SUBSCRIPTION METRICS PIPELINE HEALTH
Backend runtime counters, error ledger, reported node health
</Typography>
</Box>
</Box>
@ -149,6 +189,70 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
{/* Main Content Area */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3.5 }}>
{/* Revenue KPI Row */}
{revenueCards.length > 0 && (
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: 'repeat(3, 1fr)' },
gap: 2.5,
}}
>
{revenueCards.map((card) => (
<DoubleBezelCard
key={card.title}
interactive
bezelPadding="5px"
innerPadding="20px"
sx={{ height: '100%' }}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
<StatRing color={card.color} size={46}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '13px', fontWeight: 500 }}>$</Typography>
</StatRing>
<Box component="span" sx={statusBadgeSx(card.badgeColor)}>
{card.badge}
</Box>
</Box>
<Typography
sx={{
fontFamily: FONT_SANS,
fontSize: '11px',
fontWeight: 500,
color: C.dim,
letterSpacing: '0.04em',
}}
>
{card.title}
</Typography>
<Typography
sx={{
fontFamily: FONT_MONO,
fontSize: '26px',
fontWeight: 500,
color: C.bright,
my: 0.75,
}}
>
{card.value}
</Typography>
<Typography
sx={{
fontFamily: FONT_SANS,
fontSize: '11px',
color: C.text,
}}
>
{card.subtext}
</Typography>
</DoubleBezelCard>
))}
</Box>
)}
{/* Executive Bento Grid */}
<Box
sx={{
@ -178,10 +282,9 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
sx={{
fontFamily: FONT_SANS,
fontSize: '11px',
fontWeight: 600,
fontWeight: 500,
color: C.dim,
textTransform: 'uppercase',
letterSpacing: '0.06em',
letterSpacing: '0.04em',
}}
>
{card.title}
@ -191,7 +294,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
sx={{
fontFamily: FONT_MONO,
fontSize: '26px',
fontWeight: 700,
fontWeight: 500,
color: C.bright,
my: 0.75,
}}
@ -216,15 +319,19 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
<Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
System Nodes & Pipeline Topology
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
6 NODES HEALTHY ZERO SERVICE DEGRADATION DETECTED
{stats.nodes.length === 0
? 'NODE TELEMETRY NOT REPORTED'
: `${stats.nodes.length} REPORTED • ${nonOperationalNodes.length} NON-OPERATIONAL`}
</Typography>
</Box>
<TactileBadge tone="success" mono>
ALL OPERATIONAL
<TactileBadge tone="mono" mono>
{stats.nodes.length === 0
? 'UNAVAILABLE'
: nonOperationalNodes.length === 0 ? 'ALL REPORTED OPERATIONAL' : 'ATTENTION REQUIRED'}
</TactileBadge>
</Box>
@ -235,7 +342,11 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
gap: 2,
}}
>
{stats.nodes.map((node) => (
{stats.nodes.length === 0 ? (
<Typography sx={{ gridColumn: '1 / -1', py: 3, textAlign: 'center', color: C.dim, fontFamily: FONT_MONO, fontSize: '12px' }}>
No node-health telemetry has been reported by the backend.
</Typography>
) : stats.nodes.map((node) => (
<Box
key={node.id}
sx={{
@ -252,7 +363,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', fontWeight: 500, color: C.bright }}>
{node.name}
</Typography>
<Box
@ -260,8 +371,8 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: '#10b981',
boxShadow: '0 0 8px #10b981',
bgcolor: node.status === 'operational' ? '#10b981' : node.status === 'degraded' ? '#f59e0b' : '#ef4444',
boxShadow: `0 0 8px ${node.status === 'operational' ? '#10b981' : node.status === 'degraded' ? '#f59e0b' : '#ef4444'}`,
}}
/>
</Box>
@ -274,8 +385,8 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>
Latency: <strong style={{ color: C.bright }}>{node.latencyMs}ms</strong>
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.green400 }}>
{node.uptimePercent}% Up
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: node.status === 'operational' ? C.green400 : node.status === 'degraded' ? C.orange400 : C.red400 }}>
{node.uptimePercent}% · {node.status.toUpperCase()}
</Typography>
</Box>
</Box>
@ -283,17 +394,14 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
</Box>
</DoubleBezelCard>
{/* Live Audio & Voice Intelligence Simulator Widget */}
<DashboardSimulator />
{/* Server Operational Telemetry Logs */}
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 500, color: C.bright }}>
Operational Telemetry & Server Logs
</Typography>
<TactileBadge tone="mono" mono>
AUTO REFRESH (30s)
SERVER SNAPSHOT
</TactileBadge>
</Box>
@ -312,7 +420,7 @@ export default async function AdminOverviewPage(): Promise<React.ReactElement> {
{stats.recentErrors.length === 0 ? (
<Box component="tr">
<Box component="td" colSpan={5} sx={{ textAlign: 'center', color: C.green400, py: 3 }}>
NO OPERATIONAL ERRORS ALL C# .NET API NODES HEALTHY (100% SUCCESS RATE)
No backend errors have been recorded.
</Box>
</Box>
) : (

View file

@ -8,6 +8,18 @@ import { StatRing, TactileBadge, DoubleBezelCard } from '@d3ro/ui/components/ds'
export default async function PipelinesPage(): Promise<React.ReactElement> {
const stats = await fetchServerStats()
if (!stats.pipelines) {
return (
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Typography component="h1" sx={{ fontFamily: FONT_SANS, fontSize: '20px', fontWeight: 500, color: C.bright, mb: 1 }}>
Pipeline telemetry unavailable
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim, lineHeight: 1.7 }}>
The backend does not currently expose measured pipeline telemetry. No simulated engine, latency, accuracy, or capacity values are shown.
</Typography>
</DoubleBezelCard>
)
}
const { whisper, ollama, realtimeVoice, ragVector, meetingIntelligence } = stats.pipelines
return (
@ -42,7 +54,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
sx={{
fontFamily: FONT_SANS,
fontSize: '18px',
fontWeight: 700,
fontWeight: 500,
color: C.bright,
letterSpacing: '-0.02em',
m: 0,
@ -63,7 +75,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
mt: 0.25,
}}
>
LOCAL WHISPER OLLAMA V0.32.1 GPT-REALTIME 2.1 VECTOR RAG DIARIZATION
Local Whisper, Ollama v0.32.1, GPT-Realtime 2.1, Vector RAG, Diarization
</Typography>
</Box>
</Box>
@ -103,7 +115,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
</svg>
</StatRing>
<Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
Faster-Whisper STT Sidecar
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight }}>
@ -119,19 +131,19 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5, mb: 2.5 }}>
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>AVG LATENCY</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.bright }}>
{whisper.avgLatencyMs}ms
</Typography>
</Box>
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>SPEEDUP FACTOR</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.green400 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.green400 }}>
{whisper.speedupFactor}
</Typography>
</Box>
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>GPU VRAM</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.cyanLight }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.cyanLight }}>
{whisper.gpuVramUsage}
</Typography>
</Box>
@ -156,7 +168,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
</svg>
</StatRing>
<Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
Bundled Ollama Runtime
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.purple400 }}>
@ -172,19 +184,19 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5, mb: 2.5 }}>
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>THROUGHPUT</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.bright }}>
{ollama.tokensPerSecond} tok/s
</Typography>
</Box>
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>CONTEXT LIMIT</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.cyanLight }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.cyanLight }}>
{ollama.activeContextLimit}
</Typography>
</Box>
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>VRAM OCCUPANCY</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.purple400 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.purple400 }}>
{ollama.vramAllocated}
</Typography>
</Box>
@ -212,7 +224,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
</svg>
</StatRing>
<Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
GPT-Realtime 2.1 Live Engine
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.orange400 }}>
@ -228,19 +240,19 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5, mb: 2.5 }}>
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>LIVE STREAMS</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.orange400 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.orange400 }}>
{realtimeVoice.activeStreams} Active
</Typography>
</Box>
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>AUDIO RTT</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.bright }}>
{realtimeVoice.avgAudioRttMs}ms
</Typography>
</Box>
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>LOCAL FALLBACK</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.green400 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.green400 }}>
{realtimeVoice.localFallbackRate}
</Typography>
</Box>
@ -265,7 +277,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
</svg>
</StatRing>
<Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
SQLite Vector RAG Engine
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.green400 }}>
@ -281,19 +293,19 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5, mb: 2.5 }}>
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>INDEXED DOCS</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.bright }}>
{ragVector.indexedDocuments.toLocaleString()}
</Typography>
</Box>
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>VECTOR CHUNKS</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.cyanLight }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.cyanLight }}>
{ragVector.totalVectorChunks.toLocaleString()}
</Typography>
</Box>
<Box sx={{ p: 1.5, borderRadius: '10px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>SEARCH HIT RATE</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 700, color: C.green400 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '16px', fontWeight: 500, color: C.green400 }}>
{ragVector.topHitRatePercent}%
</Typography>
</Box>
@ -319,7 +331,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
</svg>
</StatRing>
<Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '18px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '18px', fontWeight: 500, color: C.bright }}>
Meeting Intelligence & Speaker Diarization (Phase 14~15.5)
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.accentLight }}>
@ -335,7 +347,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, 1fr)', md: 'repeat(4, 1fr)' }, gap: 2, mb: 3 }}>
<Box sx={{ p: 2, borderRadius: '12px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>SPEAKER ACCURACY</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 700, color: C.green400 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 500, color: C.green400 }}>
{meetingIntelligence.speakerAccuracyPercent}%
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
@ -344,7 +356,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
</Box>
<Box sx={{ p: 2, borderRadius: '12px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>ACTIVE MEETINGS</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 700, color: C.cyanLight }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 500, color: C.cyanLight }}>
{meetingIntelligence.activeMeetingSessions} Live
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
@ -353,7 +365,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
</Box>
<Box sx={{ p: 2, borderRadius: '12px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>TEMPLATES TODAY</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 700, color: C.purple400 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 500, color: C.purple400 }}>
{meetingIntelligence.templatesGeneratedToday} Docs
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
@ -362,7 +374,7 @@ export default async function PipelinesPage(): Promise<React.ReactElement> {
</Box>
<Box sx={{ p: 2, borderRadius: '12px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>MINDMAP EXPORTS</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 700, color: C.orange400 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '20px', fontWeight: 500, color: C.orange400 }}>
{meetingIntelligence.mindmapsExported} Maps
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>

View file

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

View file

@ -4,7 +4,7 @@
// 구독 수정/삭제 클라이언트 컴포넌트
import { useState } from 'react'
import { Box, Button } from '@mui/material'
import { Alert, Box, Button } from '@mui/material'
import { d3roFontMono } from '@d3ro/ui/theme'
import { SubscriptionForm } from '@/components/subscription-form'
import { MemoDialog } from '@/components/memo-dialog'
@ -36,9 +36,11 @@ export function SubscriptionDetailClient({
const router = useRouter()
const [deleteOpen, setDeleteOpen] = useState(false)
const [deleteLoading, setDeleteLoading] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
const handleDelete = async (memo: string): Promise<void> => {
setDeleteLoading(true)
setDeleteError(null)
try {
await callAdminApi(`admin-subscriptions?userId=${userId}`, {
method: 'DELETE',
@ -46,8 +48,8 @@ export function SubscriptionDetailClient({
})
setDeleteOpen(false)
router.refresh()
} catch {
// error handled in dialog
} catch (error) {
setDeleteError(error instanceof Error ? error.message : 'Subscription deletion failed')
} finally {
setDeleteLoading(false)
}
@ -74,6 +76,7 @@ export function SubscriptionDetailClient({
{/* admin 이상: 삭제 가능 */}
{canCreateDelete && (
<>
{deleteError && <Alert severity="error" sx={{ mt: 2 }}>{deleteError}</Alert>}
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="outlined"

View file

@ -4,57 +4,57 @@
import { Box, Typography } from '@mui/material'
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
import { getSupabaseServerClient } from '@/lib/supabase-server'
import { getSupabaseAdminClient, isSupabaseAdminConfigured } from '@/lib/supabase-admin'
import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel'
import { requireManager, hasMinRole } from '@/lib/admin-guard'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { SubscriptionDetailClient } from './client'
import { fetchUsers } from '@/lib/api-server'
interface PageProps {
params: Promise<{ id: string }>
}
export default async function SubscriptionDetailPage({ params }: PageProps): Promise<React.ReactElement> {
if (!isSupabaseAdminConfigured()) {
return (
<UnavailableAdminPanel
title="Subscription Detail"
capability="Plans, billing, payment history"
reason="SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 Supabase 관리 데이터에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다."
/>
)
}
const { id: userId } = await params
const admin = await requireManager()
const supabase = await getSupabaseServerClient()
const supabase = await getSupabaseAdminClient()
const [subRes, profileRes, auditRes] = await Promise.all([
supabase.from('subscriptions').select('*').eq('user_id', userId).maybeSingle(),
supabase.from('subscriptions').select('id, user_id, tier, status, provider, payment_provider, current_period_start, current_period_end, overage_credits, admin_note, cancel_at, created_at, updated_at').eq('user_id', userId).maybeSingle(),
supabase.from('profiles').select('id, name, tier, role').eq('id', userId).maybeSingle(),
supabase.from('audit_log').select('*')
supabase.from('audit_log').select('id, action, target_type, target_id, memo, created_at')
.eq('target_id', userId)
.eq('target_type', 'subscription')
.order('created_at', { ascending: false })
.limit(20),
])
// Fallback to mock user
const allUsers = await fetchUsers()
const matchedUser = allUsers.find((u) => String(u.id) === userId || u.uid === userId) || allUsers[0]
if (subRes.error) throw new Error(`Supabase subscription failed: ${subRes.error.message}`)
if (profileRes.error) throw new Error(`Supabase profile failed: ${profileRes.error.message}`)
if (auditRes.error) throw new Error(`Supabase audit log failed: ${auditRes.error.message}`)
if (!profileRes.data) notFound()
const profile = profileRes.data || {
id: matchedUser.uid,
name: matchedUser.name,
tier: matchedUser.tier,
role: matchedUser.role,
const profile = profileRes.data
const sub = subRes.data
const auditLogs = auditRes.data ?? []
if (sub && (!['free', 'pro', 'pro_plus'].includes(sub.tier) ||
!['active', 'canceled', 'past_due', 'expired'].includes(sub.status) ||
!Number.isInteger(sub.overage_credits))) {
throw new Error('Supabase subscription response is invalid')
}
const sub = subRes.data || {
tier: matchedUser.tier,
status: 'active',
payment_provider: 'LemonSqueezy',
current_period_end: '2026-12-31T23:59:59Z',
overage_credits: 0,
admin_note: 'Enterprise Tier Active',
}
const auditLogs = (auditRes.data && auditRes.data.length > 0) ? auditRes.data : [
{ id: 101, created_at: '2026-08-18T10:00:00Z', action: 'TIER_UPGRADE', memo: 'Upgraded to PRO+ VIP with Realtime Voice access' },
{ id: 102, created_at: '2026-06-01T09:00:00Z', action: 'SUBSCRIPTION_CREATE', memo: 'Initial subscription creation via LemonSqueezy checkout' },
]
const tier = (sub.tier as string) || (profile.tier as string) || 'free'
const tier = (sub?.tier as string | undefined) || (profile.tier as string) || 'free'
const isProPlus = tier === 'pro_plus'
const isPro = tier === 'pro'
@ -90,7 +90,7 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
sx={{
fontFamily: FONT_SANS,
fontSize: '20px',
fontWeight: 700,
fontWeight: 500,
color: C.bright,
letterSpacing: '-0.02em',
m: 0,
@ -119,7 +119,7 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1fr 1fr' }, gap: 3 }}>
{/* User Info Card */}
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright, mb: 2 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright, mb: 2 }}>
Account Identifiers
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, fontFamily: FONT_SANS, fontSize: '13px' }}>
@ -131,15 +131,15 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
{/* Subscription State Card */}
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright, mb: 2 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright, mb: 2 }}>
Contract Status & Pricing
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, fontFamily: FONT_SANS, fontSize: '13px' }}>
<Row label="Plan Level" value={isProPlus ? 'PRO+ VIP ($29/mo)' : isPro ? 'PRO ($12/mo)' : 'FREE'} />
<Row label="Current Status" value={((sub.status as string) ?? 'active').toUpperCase()} />
<Row label="Payment Gateway" value={((sub.payment_provider as string) ?? 'LemonSqueezy').toUpperCase()} />
<Row label="Contract Expires / Renews" value={sub.current_period_end ? new Date(sub.current_period_end as string).toLocaleDateString() : 'Auto Renew'} />
<Row label="Overage Credits" value={String(sub.overage_credits ?? 0)} isMono />
<Row label="Plan Level" value={isProPlus ? 'PRO+ VIP' : isPro ? 'PRO' : 'FREE'} />
<Row label="Current Status" value={sub?.status ? String(sub.status).toUpperCase() : 'NO SUBSCRIPTION'} />
<Row label="Payment Gateway" value={sub?.payment_provider ? String(sub.payment_provider).toUpperCase() : 'NOT ASSIGNED'} />
<Row label="Contract Expires / Renews" value={sub?.current_period_end ? new Date(sub.current_period_end as string).toLocaleDateString() : 'NOT SET'} />
<Row label="Overage Credits" value={sub ? String(sub.overage_credits) : 'NO SUBSCRIPTION'} isMono />
</Box>
</DoubleBezelCard>
</Box>
@ -151,10 +151,10 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
canEdit={true}
canCreateDelete={hasMinRole(admin, 'admin')}
initialSub={sub ? ({
tier: (sub.tier as 'free' | 'pro' | 'pro_plus') ?? 'free',
status: (sub.status as 'active' | 'canceled' | 'past_due' | 'expired') ?? 'active',
tier: sub.tier as 'free' | 'pro' | 'pro_plus',
status: sub.status as 'active' | 'canceled' | 'past_due' | 'expired',
currentPeriodEnd: (sub.current_period_end as string | null) ?? null,
overageCredits: (sub.overage_credits as number) ?? 0,
overageCredits: sub.overage_credits as number,
adminNote: (sub.admin_note as string | null) ?? null,
}) : undefined}
/>
@ -162,7 +162,7 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
{/* Audit Trail Table */}
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 500, color: C.bright }}>
Subscription Security Audit Trail
</Typography>
<TactileBadge tone="mono" mono>
@ -181,7 +181,13 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
</Box>
</Box>
<Box component="tbody">
{auditLogs.map((log) => (
{auditLogs.length === 0 ? (
<Box component="tr">
<Box component="td" colSpan={4} sx={{ textAlign: 'center', color: C.dim, py: 3 }}>
No subscription audit events have been recorded.
</Box>
</Box>
) : auditLogs.map((log) => (
<Box component="tr" key={log.id as number}>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
{new Date(log.created_at as string).toLocaleString()}

View file

@ -28,7 +28,7 @@ export function NewSubscriptionClient({ initialUserId }: NewSubscriptionClientPr
size="small"
value={userId}
onChange={(e) => setUserId(e.target.value)}
placeholder="e.g. usr_d3ro_001 or Supabase UUID..."
placeholder="Supabase user UUID"
sx={{
'& .MuiOutlinedInput-root': {
bgcolor: 'rgba(10, 17, 31, 0.7)',

View file

@ -49,7 +49,7 @@ export default async function NewSubscriptionPage({ searchParams }: PageProps):
sx={{
fontFamily: FONT_SANS,
fontSize: '20px',
fontWeight: 700,
fontWeight: 500,
color: C.bright,
letterSpacing: '-0.02em',
m: 0,

View file

@ -1,11 +1,12 @@
// apps/admin/src/app/(admin)/subscriptions/page.tsx
// D3RO Voice — Subscriptions & ARR Revenue Console (Midnight Glass v2)
// D3RO Voice — Subscription Operations Console
import { Box, Typography, Button } from '@mui/material'
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, filterBtnSx, statusBadgeSx, primaryButtonSx } from '@/lib/console-theme'
import { DoubleBezelCard, TactileBadge, StatRing } from '@d3ro/ui/components/ds'
import { getSupabaseServerClient } from '@/lib/supabase-server'
import { fetchServerStats } from '@/lib/api-server'
import { getSupabaseAdminClient, isSupabaseAdminConfigured } from '@/lib/supabase-admin'
import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel'
import { hasMinRole, requireManager } from '@/lib/admin-guard'
import { LicenseIssuerButton } from '@/components/license-issuer-button'
import Link from 'next/link'
@ -19,7 +20,6 @@ interface SubRow {
cancel_at: string | null
renewal_failures: number
profile_name: string | null
mrrAmount: number
}
interface PageProps {
@ -27,57 +27,68 @@ interface PageProps {
}
export default async function AdminSubscriptionsPage({ searchParams }: PageProps): Promise<React.ReactElement> {
if (!isSupabaseAdminConfigured()) {
return (
<UnavailableAdminPanel
title="Subscription Management"
capability="Plans, billing, payment history"
reason="SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 Supabase 관리 데이터에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다."
/>
)
}
const params = await searchParams
const statusFilter = params.status ?? 'all'
const supabase = await getSupabaseServerClient()
const serverStats = await fetchServerStats()
if (!['all', 'active', 'canceled', 'past_due', 'expired'].includes(statusFilter)) {
throw new Error('Invalid subscription status filter')
}
const admin = await requireManager()
const supabase = await getSupabaseAdminClient()
const subQuery = supabase
const rawSubsArr: Array<Record<string, unknown>> = []
for (let page = 0; page < 100; page += 1) {
let query = supabase
.from('subscriptions')
.select('id, user_id, tier, status, payment_provider, current_period_end, cancel_at')
.select('id, user_id, tier, status, payment_provider, current_period_end, cancel_at, renewal_failures')
.order('current_period_end', { ascending: true })
.limit(100)
if (statusFilter !== 'all') subQuery.eq('status', statusFilter)
const { data: rawSubs } = await subQuery
const rawSubsArr = (rawSubs ?? []) as Array<Record<string, unknown>>
.range(page * 500, page * 500 + 499)
if (statusFilter !== 'all') query = query.eq('status', statusFilter)
const { data, error } = await query
if (error) throw new Error('Supabase subscriptions failed')
rawSubsArr.push(...((data ?? []) as Array<Record<string, unknown>>))
if ((data?.length ?? 0) < 500) break
if (page === 99) throw new Error('Supabase subscription directory exceeds the supported administrative window')
}
let subs: SubRow[] = []
if (rawSubsArr.length > 0) {
const userIds = rawSubsArr.map((s) => s.user_id as string)
const { data: rawProfiles } = userIds.length > 0
? await supabase.from('profiles').select('id, name').in('id', userIds)
: { data: [] }
const profileChunks = await Promise.all(Array.from({ length: Math.ceil(userIds.length / 200) }, (_, index) =>
supabase.from('profiles').select('id, name').in('id', userIds.slice(index * 200, index * 200 + 200))))
if (profileChunks.some((result) => result.error)) throw new Error('Supabase subscription profiles failed')
const rawProfiles = profileChunks.flatMap((result) => result.data ?? [])
const profileMap = new Map(
((rawProfiles ?? []) as Array<Record<string, unknown>>).map((p) => [p.id as string, (p.name as string) ?? null])
)
subs = rawSubsArr.map((row) => ({
id: row.id as string,
user_id: row.user_id as string,
tier: (row.tier as string) ?? 'free',
status: (row.status as string) ?? 'active',
payment_provider: (row.payment_provider as string) ?? 'Stripe',
current_period_end: row.current_period_end as string | null,
cancel_at: row.cancel_at as string | null,
renewal_failures: (row.renewal_failures as number | undefined) ?? 0,
profile_name: profileMap.get(row.user_id as string) ?? null,
mrrAmount: row.tier === 'enterprise' ? 120 : row.tier === 'team' ? 25 : row.tier === 'pro_plus' ? 19.9 : row.tier === 'pro' ? 9.9 : 0,
}))
} else {
// Rich Mock Fallback Subscriptions
subs = [
{ id: 'sub_001', user_id: 'usr_d3ro_001', tier: 'enterprise', status: 'active', payment_provider: 'Stripe', current_period_end: '2027-01-15T00:00:00Z', cancel_at: null, renewal_failures: 0, profile_name: 'D3RO System Architect', mrrAmount: 120 },
{ id: 'sub_002', user_id: 'usr_d3ro_002', tier: 'team', status: 'active', payment_provider: 'Toss Payments', current_period_end: '2026-11-10T00:00:00Z', cancel_at: null, renewal_failures: 0, profile_name: 'Sarah Kim (Design Team)', mrrAmount: 75 },
{ id: 'sub_003', user_id: 'usr_d3ro_003', tier: 'pro_plus', status: 'active', payment_provider: 'Stripe', current_period_end: '2026-10-02T00:00:00Z', cancel_at: null, renewal_failures: 0, profile_name: 'Minho Park', mrrAmount: 19.9 },
{ id: 'sub_004', user_id: 'usr_d3ro_004', tier: 'pro', status: 'active', payment_provider: 'Payple', current_period_end: '2026-09-18T00:00:00Z', cancel_at: null, renewal_failures: 0, profile_name: 'Alex Chen', mrrAmount: 9.9 },
{ id: 'sub_005', user_id: 'usr_d3ro_005', tier: 'pro', status: 'active', payment_provider: 'Toss Payments', current_period_end: '2026-09-01T00:00:00Z', cancel_at: null, renewal_failures: 0, profile_name: 'Jisoo Lee', mrrAmount: 9.9 },
{ id: 'sub_006', user_id: 'usr_d3ro_006', tier: 'pro_plus', status: 'active', payment_provider: 'Stripe', current_period_end: '2026-12-20T00:00:00Z', cancel_at: null, renewal_failures: 0, profile_name: 'David Wilson', mrrAmount: 19.9 },
{ id: 'sub_007', user_id: 'usr_d3ro_007', tier: 'free', status: 'active', payment_provider: 'None', current_period_end: null, cancel_at: null, renewal_failures: 0, profile_name: 'Hyunjin Choi', mrrAmount: 0 },
{ id: 'sub_008', user_id: 'usr_d3ro_008', tier: 'free', status: 'active', payment_provider: 'None', current_period_end: null, cancel_at: null, renewal_failures: 0, profile_name: 'Elena Rostova', mrrAmount: 0 },
]
subs = rawSubsArr.map((row) => {
if (typeof row.id !== 'string' || typeof row.user_id !== 'string' ||
typeof row.tier !== 'string' || typeof row.status !== 'string' ||
typeof row.payment_provider !== 'string' || !Number.isInteger(row.renewal_failures)) {
throw new Error('Supabase subscription response contains an invalid row')
}
return {
id: row.id,
user_id: row.user_id,
tier: row.tier,
status: row.status,
payment_provider: row.payment_provider,
current_period_end: typeof row.current_period_end === 'string' ? row.current_period_end : null,
cancel_at: typeof row.cancel_at === 'string' ? row.cancel_at : null,
renewal_failures: row.renewal_failures as number,
profile_name: profileMap.get(row.user_id) ?? null,
}
})
}
const filteredSubs = statusFilter === 'all' ? subs : subs.filter((s) => s.status === statusFilter)
@ -114,16 +125,16 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
sx={{
fontFamily: FONT_SANS,
fontSize: '20px',
fontWeight: 700,
fontWeight: 500,
color: C.bright,
letterSpacing: '-0.02em',
m: 0,
}}
>
Subscriptions & ARR Analytics
Subscription Operations
</Typography>
<TactileBadge ledColor="purple" ledPulse tone="accent" mono>
${serverStats.arrUsd.toLocaleString()} ARR
{subs.length} RECORDS
</TactileBadge>
</Box>
<Typography
@ -135,7 +146,7 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
mt: 0.25,
}}
>
LEMONSQUEEZY SYNC 3-TIER MONETIZATION AUTO RENEWAL
Supabase subscriptions, payment providers, renewal state
</Typography>
</Box>
</Box>
@ -149,12 +160,14 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
</Link>
))}
<Box sx={{ height: 20, width: '1px', bgcolor: C.borderHl, mx: 0.5 }} />
<LicenseIssuerButton />
{hasMinRole(admin, 'super_admin') && <LicenseIssuerButton />}
{hasMinRole(admin, 'admin') && (
<Link href="/subscriptions/new" style={{ textDecoration: 'none' }}>
<Button variant="contained" size="small" sx={primaryButtonSx}>
+ Create Subscription
</Button>
</Link>
)}
</Box>
</Box>
@ -163,51 +176,51 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
<DoubleBezelCard interactive bezelPadding="5px" innerPadding="20px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', fontWeight: 600, color: C.dim, textTransform: 'uppercase' }}>
Monthly Recurring (MRR)
Total Subscription Records
</Typography>
<StatRing color="purple" size={38}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', fontWeight: 700 }}>$</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', fontWeight: 500 }}>#</Typography>
</StatRing>
</Box>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '24px', fontWeight: 700, color: C.bright }}>
${serverStats.mrrUsd.toLocaleString()}
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '24px', fontWeight: 500, color: C.bright }}>
{subs.length.toLocaleString()}
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.green400, mt: 0.5 }}>
18.4% vs last month
Persisted in Supabase
</Typography>
</DoubleBezelCard>
<DoubleBezelCard interactive bezelPadding="5px" innerPadding="20px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', fontWeight: 600, color: C.dim, textTransform: 'uppercase' }}>
Paid Subscribers (Pro/Pro+)
Active Subscriptions
</Typography>
<StatRing color="blue" size={38}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', fontWeight: 700 }}>VIP</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', fontWeight: 500 }}>ON</Typography>
</StatRing>
</Box>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '24px', fontWeight: 700, color: C.bright }}>
{(serverStats.tierDistribution.pro + serverStats.tierDistribution.pro_plus).toLocaleString()} Paid
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '24px', fontWeight: 500, color: C.bright }}>
{subs.filter((subscription) => subscription.status === 'active').length.toLocaleString()}
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.cyanLight, mt: 0.5 }}>
{serverStats.tierDistribution.pro_plus} Pro+ {serverStats.tierDistribution.pro} Pro
Current status = active
</Typography>
</DoubleBezelCard>
<DoubleBezelCard interactive bezelPadding="5px" innerPadding="20px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', fontWeight: 600, color: C.dim, textTransform: 'uppercase' }}>
Renewal Success Rate
Renewal Failures
</Typography>
<StatRing color="green" size={38}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', fontWeight: 700 }}>%</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', fontWeight: 500 }}>%</Typography>
</StatRing>
</Box>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '24px', fontWeight: 700, color: C.green400 }}>
99.4%
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '24px', fontWeight: 500, color: C.green400 }}>
{subs.reduce((total, subscription) => total + subscription.renewal_failures, 0).toLocaleString()}
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>
0.6% Churn Fast Retry System
Persisted retry failures
</Typography>
</DoubleBezelCard>
</Box>
@ -215,8 +228,8 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
{/* Subscription Table */}
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
Active Subscription Contracts
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
Subscription Contracts
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
SHOWING {filteredSubs.length} RECORDS
@ -230,7 +243,7 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
<Box component="th">SUBSCRIBER</Box>
<Box component="th">TIER</Box>
<Box component="th">STATUS</Box>
<Box component="th">MRR VALUE</Box>
<Box component="th">RENEWAL FAILURES</Box>
<Box component="th">PROVIDER</Box>
<Box component="th">EXPIRES / RENEWS</Box>
<Box component="th" sx={{ textAlign: 'right' }}>ACTION</Box>
@ -269,14 +282,14 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
{s.status.toUpperCase()}
</Box>
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '13px', fontWeight: 700, color: C.bright }}>
${s.mrrAmount}/mo
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '13px', fontWeight: 500, color: C.bright }}>
{s.renewal_failures}
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
{s.payment_provider}
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.text }}>
{s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : 'Free Tier'}
{s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : 'Not set'}
</Box>
<Box component="td" sx={{ textAlign: 'right' }}>
<Link href={`/subscriptions/${s.user_id}`} style={{ textDecoration: 'none' }}>

View file

@ -1,315 +1,11 @@
// apps/admin/src/app/(admin)/support/page.tsx
// D3RO Voice — Customer Support (CA/CS) & Diagnostics Console (Midnight Glass v2)
import { Box, Typography, Button } from '@mui/material'
import {
C,
FONT_SANS,
FONT_MONO,
panelSx,
tableSx,
statusBadgeSx,
primaryButtonSx,
} from '@/lib/console-theme'
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
interface TicketRow {
id: string
customerEmail: string
category: string
priority: 'urgent' | 'high' | 'normal'
status: 'open' | 'in_progress' | 'resolved'
subject: string
createdAt: string
slaRemaining: string
machineId: string
gpuAccelerated: boolean
audioDevice: string
aiSuggestedFix: string
}
export default async function AdminSupportPage(): Promise<React.ReactElement> {
const tickets: TicketRow[] = [
{
id: 'TCK-9401',
customerEmail: 'minho.park@linecorp.com',
category: 'CUDA GPU / VRAM',
priority: 'urgent',
status: 'open',
subject: 'CUDA out of memory error during 2-hour long meeting mode transcription',
createdAt: '12m ago',
slaRemaining: '18m left',
machineId: 'win-rtx3080-99af',
gpuAccelerated: true,
audioDevice: 'Yamaha AG03 USB Audio',
aiSuggestedFix: 'Recommend switching model to large-v3-turbo with int8 quantization and enabling 10-minute auto-chunking.',
},
{
id: 'TCK-9402',
customerEmail: 'tax_admin@krafton.com',
category: 'Billing / Tax Invoice',
priority: 'normal',
status: 'open',
subject: '법인 정기구독 전자세금계산서 사업자등록번호 변경 요청',
createdAt: '45m ago',
slaRemaining: '3h 15m left',
machineId: 'mac-m3max-01bc',
gpuAccelerated: false,
audioDevice: 'Built-in Microphone',
aiSuggestedFix: 'Verify business registration certificate on NTS HomeTax and re-issue Toss Payments tax invoice automatically.',
},
{
id: 'TCK-9403',
customerEmail: 'sarah.k@designstudio.io',
category: 'Audio Hardware / STT',
priority: 'high',
status: 'in_progress',
subject: 'Microphone permission denied after Windows 11 24H2 update',
createdAt: '1h 10m ago',
slaRemaining: '45m left',
machineId: 'win-thinkpad-44a1',
gpuAccelerated: false,
audioDevice: 'Realtek High Definition Audio',
aiSuggestedFix: 'Guide user to Windows Settings > Privacy & Security > Microphone > Let desktop apps access your microphone.',
},
{
id: 'TCK-9404',
customerEmail: 'alex.chen@cursor.sh',
category: 'Feature / Prompt',
priority: 'normal',
status: 'resolved',
subject: 'Request custom dictionary sync via CLI webhook',
createdAt: '5h ago',
slaRemaining: 'Met SLA (24m)',
machineId: 'linux-popos-77e2',
gpuAccelerated: true,
audioDevice: 'Shure SM7B + Scarlett Solo',
aiSuggestedFix: 'Provided OpenAPI documentation for /api/dictionary/sync and token authentication header guide.',
},
]
import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel'
export default function AdminSupportPage(): React.ReactElement {
return (
<>
{/* Header */}
<Box
sx={{
...panelSx,
minHeight: 84,
px: { xs: 2.5, md: 4 },
py: 2,
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
}}
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 0.5 }}>
<Typography
sx={{
fontFamily: FONT_SANS,
fontSize: { xs: '20px', md: '24px' },
fontWeight: 800,
color: C.bright,
letterSpacing: '-0.02em',
}}
>
Customer Support (CA/CS) & Diagnostics Desk
</Typography>
<TactileBadge mono tone="warning">
4 OPEN TICKETS
</TactileBadge>
</Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>
AI-first customer triage, live hardware telemetry inspector, automated 7-day refund verification.
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Button
sx={{
...primaryButtonSx,
bgcolor: 'rgba(139, 92, 246, 0.15)',
color: C.purple400,
border: `1px solid ${C.borderHl}`,
'&:hover': { bgcolor: 'rgba(139, 92, 246, 0.25)' },
}}
>
Sync Channel.io / Zendesk
</Button>
</Box>
</Box>
{/* Support KPI Metrics */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' },
gap: 2,
}}
>
<DoubleBezelCard interactive>
<Box sx={{ p: 2.5 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
Pending Tickets
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.orange400, my: 0.5 }}>
4 <span style={{ fontSize: '14px', color: C.dim, fontWeight: 500 }}>/ 180 total</span>
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.orange400 }}>
1 Urgent Ticket
</Typography>
</Box>
</DoubleBezelCard>
<DoubleBezelCard interactive>
<Box sx={{ p: 2.5 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
First Response Time
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.cyanLight, my: 0.5 }}>
4.2 min
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.green400 }}>
99.4% SLA Compliance
</Typography>
</Box>
</DoubleBezelCard>
<DoubleBezelCard interactive>
<Box sx={{ p: 2.5 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
AI Auto-Resolution Rate
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.bright, my: 0.5 }}>
78.5%
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.green400 }}>
141 resolved by AI Bot
</Typography>
</Box>
</DoubleBezelCard>
<DoubleBezelCard interactive>
<Box sx={{ p: 2.5 }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
CSAT Satisfaction Score
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.green400, my: 0.5 }}>
4.92 <span style={{ fontSize: '14px', color: C.dim, fontWeight: 500 }}>/ 5.0</span>
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
Based on 92 ratings
</Typography>
</Box>
</DoubleBezelCard>
</Box>
{/* Tickets Queue Table */}
<Box sx={{ ...panelSx, p: { xs: 2, md: 3 } }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
Incoming Ticket Queue & Diagnostic Payloads
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
Real-time Channel.io Webhook Active
</Typography>
</Box>
<Box sx={{ overflowX: 'auto' }}>
<Box component="table" sx={tableSx}>
<thead>
<tr>
<th>Ticket ID</th>
<th>Customer / User</th>
<th>Category</th>
<th>Subject & Issue</th>
<th>Priority</th>
<th>SLA Timer</th>
<th>Hardware / Telemetry</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{tickets.map((t) => (
<tr key={t.id}>
<td>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', fontWeight: 700, color: C.cyanLight }}>
{t.id}
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>
{t.createdAt}
</Typography>
</td>
<td>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', fontWeight: 600, color: C.bright }}>
{t.customerEmail}
</Typography>
</td>
<td>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
{t.category}
</Typography>
</td>
<td style={{ maxWidth: 320 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.bright, fontWeight: 500 }} noWrap>
{t.subject}
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.cyanLight }} noWrap>
💡 AI: {t.aiSuggestedFix}
</Typography>
</td>
<td>
<Box
sx={{
...statusBadgeSx,
bgcolor: t.priority === 'urgent' ? 'rgba(239, 68, 68, 0.15)' : t.priority === 'high' ? 'rgba(245, 158, 11, 0.15)' : 'rgba(59, 130, 246, 0.15)',
color: t.priority === 'urgent' ? C.red400 : t.priority === 'high' ? C.orange400 : C.cyanLight,
border: `1px solid ${t.priority === 'urgent' ? 'rgba(239, 68, 68, 0.3)' : t.priority === 'high' ? 'rgba(245, 158, 11, 0.3)' : 'rgba(59, 130, 246, 0.3)'}`,
}}
>
{t.priority.toUpperCase()}
</Box>
</td>
<td>
<Typography
sx={{
fontFamily: FONT_MONO,
fontSize: '12px',
fontWeight: 600,
color: t.priority === 'urgent' ? C.red400 : C.green400,
}}
>
{t.slaRemaining}
</Typography>
</td>
<td>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.bright }}>
{t.audioDevice}
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '9px', color: C.dim }}>
{t.machineId} GPU: {t.gpuAccelerated ? 'ON' : 'OFF'}
</Typography>
</td>
<td>
<Box
sx={{
...statusBadgeSx,
bgcolor: t.status === 'open' ? 'rgba(245, 158, 11, 0.15)' : t.status === 'in_progress' ? 'rgba(59, 130, 246, 0.15)' : 'rgba(16, 185, 129, 0.15)',
color: t.status === 'open' ? C.orange400 : t.status === 'in_progress' ? C.cyanLight : C.green400,
border: `1px solid ${t.status === 'open' ? 'rgba(245, 158, 11, 0.3)' : t.status === 'in_progress' ? 'rgba(59, 130, 246, 0.3)' : 'rgba(16, 185, 129, 0.3)'}`,
}}
>
{t.status.toUpperCase()}
</Box>
</td>
</tr>
))}
</tbody>
</Box>
</Box>
</Box>
</>
<UnavailableAdminPanel
title="Customer Support Desk"
capability="Tickets, SLA, diagnostics, refunds"
reason="지원 티켓 공급자와 진단 수집 시스템의 인증된 서버 계약이 구성되지 않았습니다. 고객·장치·SLA 정보를 추정하거나 가짜 티켓으로 대체하지 않습니다."
/>
)
}

View file

@ -94,7 +94,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
sx={{
fontFamily: FONT_SANS,
fontSize: '20px',
fontWeight: 700,
fontWeight: 500,
color: C.bright,
letterSpacing: '-0.02em',
m: 0,
@ -115,7 +115,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
mt: 0.25,
}}
>
REALTIME STT AUDIO MINUTES LLM TOKEN COUNTER CLOUD PROVIDER ATTRIBUTION
Realtime STT audio minutes, LLM token counter, cloud provider attribution
</Typography>
</Box>
</Box>
@ -141,7 +141,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
{m.icon}
</StatRing>
</Box>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '26px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '26px', fontWeight: 500, color: C.bright }}>
{m.value}
</Typography>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.text, mt: 0.5 }}>
@ -154,7 +154,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
{/* STT Provider Usage Breakdown Table */}
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
🎙 Speech-to-Text (STT) Transcription Metrics by Provider
</Typography>
<TactileBadge tone="accent" mono>
@ -194,7 +194,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
{p.avgLatencyMs}ms
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '13px', color: C.green400, fontWeight: 700, textAlign: 'right' }}>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '13px', color: C.green400, fontWeight: 500, textAlign: 'right' }}>
${p.totalCost.toFixed(4)}
</Box>
</Box>
@ -206,7 +206,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
{/* Feature Token Distribution Visual Progress Bars */}
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright, mb: 2.5 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright, mb: 2.5 }}>
Token & Compute Distribution by Feature
</Typography>
@ -240,7 +240,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
{/* User Breakdown Table */}
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 500, color: C.bright }}>
Cost Attribution by Top Power Users
</Typography>
<TactileBadge tone="mono" mono>
@ -274,7 +274,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
{u.totalTokens.toLocaleString()}
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '13px', color: C.green400, fontWeight: 700, textAlign: 'right' }}>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '13px', color: C.green400, fontWeight: 500, textAlign: 'right' }}>
${u.totalCost.toFixed(6)}
</Box>
</Box>
@ -287,7 +287,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
{/* LLM Model Breakdown Table */}
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 500, color: C.bright }}>
🧠 Cost & Token Breakdown by LLM Model Engine
</Typography>
<TactileBadge tone="accent" mono>
@ -321,7 +321,7 @@ export default async function AdminUsagePage(): Promise<React.ReactElement> {
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
{m.totalTokens.toLocaleString()}
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '13px', color: C.green400, fontWeight: 700, textAlign: 'right' }}>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '13px', color: C.green400, fontWeight: 500, textAlign: 'right' }}>
${m.totalCost.toFixed(6)}
</Box>
</Box>

View file

@ -4,63 +4,60 @@
import { Box, Typography } from '@mui/material'
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
import { getSupabaseServerClient } from '@/lib/supabase-server'
import { getSupabaseAdminClient, isSupabaseAdminConfigured } from '@/lib/supabase-admin'
import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel'
import { requireManager, isAdmin } from '@/lib/admin-guard'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { RoleChangeButton } from './role-change-button'
import { PaymentHistory } from '@/components/payment-history'
import { fetchUsers } from '@/lib/api-server'
interface PageProps {
params: Promise<{ id: string }>
}
export default async function AdminUserDetailPage({ params }: PageProps): Promise<React.ReactElement> {
if (!isSupabaseAdminConfigured()) {
return (
<UnavailableAdminPanel
title="User Account Detail"
capability="Accounts, tiers, roles, usage"
reason="SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 Supabase 관리 데이터에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다."
/>
)
}
const { id } = await params
const admin = await requireManager()
const supabase = await getSupabaseServerClient()
const supabase = await getSupabaseAdminClient()
const [profileRes, subRes, usageRes] = await Promise.all([
supabase.from('profiles').select('*').eq('id', id).maybeSingle(),
supabase.from('subscriptions').select('*').eq('user_id', id).maybeSingle(),
supabase.from('daily_usage').select('*')
const [authRes, profileRes, subRes, usageRes] = await Promise.all([
supabase.auth.admin.getUserById(id),
supabase.from('profiles').select('id, name, locale, tier, role, created_at, updated_at').eq('id', id).maybeSingle(),
supabase.from('subscriptions').select('id, user_id, tier, status, provider, payment_provider, current_period_start, current_period_end, overage_credits, admin_note, cancel_at, created_at, updated_at').eq('user_id', id).maybeSingle(),
supabase.from('daily_usage').select('date, feature, count')
.eq('user_id', id)
.gte('date', new Date(Date.now() - 30 * 86400000).toISOString().split('T')[0])
.order('date', { ascending: false }),
])
// If supabase profile not found, fallback to rich mock user dataset
const allUsers = await fetchUsers()
const matchedMock = allUsers.find((u) => String(u.id) === id || u.uid === id) || allUsers[0]
if (authRes.error) throw new Error(`Supabase auth user failed: ${authRes.error.message}`)
if (profileRes.error) throw new Error(`Supabase profile failed: ${profileRes.error.message}`)
if (subRes.error) throw new Error(`Supabase subscription failed: ${subRes.error.message}`)
if (usageRes.error) throw new Error(`Supabase usage failed: ${usageRes.error.message}`)
if (!authRes.data.user || !profileRes.data) notFound()
const profile = profileRes.data || {
id: matchedMock.uid,
name: matchedMock.name,
email: matchedMock.email,
tier: matchedMock.tier,
role: matchedMock.role,
locale: 'ko-KR',
created_at: matchedMock.createdAt,
last_login: matchedMock.lastLoginAt,
last_device: matchedMock.lastActiveDevice,
const authUser = authRes.data.user
const profile = {
...profileRes.data,
email: authUser.email ?? null,
last_login: authUser.last_sign_in_at ?? null,
}
const sub = subRes.data
const usage = usageRes.data ?? []
const sub = subRes.data || {
status: 'active',
payment_provider: 'LemonSqueezy',
current_period_end: '2026-12-31T23:59:59Z',
cancel_at: null,
}
const usage = (usageRes.data && usageRes.data.length > 0) ? usageRes.data : [
{ date: '2026-08-19', feature: 'Realtime Dictation (Whisper Turbo)', count: 42 },
{ date: '2026-08-19', feature: 'Meeting Mode + Multi-Doc', count: 4 },
{ date: '2026-08-18', feature: 'Speaker Diarization (Pyannote)', count: 12 },
{ date: '2026-08-18', feature: 'SQLite Vector RAG Query', count: 18 },
{ date: '2026-08-17', feature: 'Auto Polish & Refine', count: 35 },
]
const tier = (profile.tier as string) ?? 'free'
const tier = typeof profile.tier === 'string' && ['free', 'pro', 'pro_plus'].includes(profile.tier)
? profile.tier
: null
const isProPlus = tier === 'pro_plus'
const isPro = tier === 'pro'
const userRole = ((profile.role as string) ?? 'user') as 'user' | 'manager' | 'admin' | 'super_admin'
@ -97,7 +94,7 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
sx={{
fontFamily: FONT_SANS,
fontSize: '20px',
fontWeight: 700,
fontWeight: 500,
color: C.bright,
letterSpacing: '-0.02em',
m: 0,
@ -106,7 +103,7 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
User Profile Console
</Typography>
<Box component="span" sx={statusBadgeSx(isProPlus ? 'purple' : isPro ? 'green' : 'blue')}>
{isProPlus ? 'PRO+ VIP' : tier.toUpperCase()}
{isProPlus ? 'PRO+ VIP' : tier ? tier.toUpperCase() : 'TIER UNAVAILABLE'}
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 0.25 }}>
@ -147,19 +144,19 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
fontWeight: 700,
fontWeight: 500,
fontSize: '20px',
boxShadow: '0 0 20px rgba(59, 130, 246, 0.4)',
}}
>
{((profile.name as string) || 'U').charAt(0)}
{((profile.name as string) || (profile.email as string) || '?').charAt(0).toUpperCase()}
</Box>
<Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '18px', fontWeight: 700, color: C.bright }}>
{(profile.name as string) || 'D3RO User'}
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '18px', fontWeight: 500, color: C.bright }}>
{(profile.name as string) || 'Name not provided'}
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
{(profile.email as string) || 'user@d3ro.voice'}
{(profile.email as string) || 'Email unavailable'}
</Typography>
</Box>
</Box>
@ -167,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' }}>
<Row label="Account UID" value={(profile.id as string) || id} isMono />
<Row label="Assigned Role" value={userRole.toUpperCase()} valueColor={userRole === 'super_admin' ? C.purple400 : C.cyanLight} isMono />
<Row label="Language Locale" value={(profile.locale as string) || 'ko-KR'} isMono />
<Row label="Active Platform" value={(profile.last_device as string) || 'Windows 11 x64 (Build 26100)'} />
<Row label="Language Locale" value={(profile.locale as string) || 'Not reported'} isMono />
<Row label="Created Date" value={new Date(profile.created_at as string).toLocaleDateString()} />
<Row label="Last Active Session" value={profile.last_login ? new Date(profile.last_login as string).toLocaleString() : 'Recent'} />
<Row label="Last Active Session" value={profile.last_login ? new Date(profile.last_login as string).toLocaleString() : 'Never signed in'} />
</Box>
</DoubleBezelCard>
{/* Subscription & Quota Card */}
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
Subscription & Quota Entitlements
</Typography>
<Box component="span" sx={statusBadgeSx((sub.status as string) === 'active' ? 'green' : 'red')}>
{((sub.status as string) || 'ACTIVE').toUpperCase()}
<Box component="span" sx={statusBadgeSx((sub?.status as string) === 'active' ? 'green' : 'red')}>
{sub?.status ? String(sub.status).toUpperCase() : 'NO SUBSCRIPTION'}
</Box>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, mb: 3 }}>
<Row label="Plan Tier" value={isProPlus ? 'PRO+ VIP ($29/mo)' : isPro ? 'PRO ($12/mo)' : 'FREE TIER'} />
<Row label="Billing Provider" value={(sub.payment_provider as string) || 'LemonSqueezy'} />
<Row label="Current Period End" value={sub.current_period_end ? new Date(sub.current_period_end as string).toLocaleDateString() : 'Auto Renew'} />
<Row label="Cancel Scheduled" value={sub.cancel_at ? new Date(sub.cancel_at as string).toLocaleDateString() : 'None (Active)'} />
<Row label="Plan Tier" value={isProPlus ? 'PRO+ VIP' : isPro ? 'PRO' : tier === 'free' ? 'FREE' : 'Unavailable'} />
<Row label="Billing Provider" value={sub?.payment_provider ? String(sub.payment_provider) : 'Not assigned'} />
<Row label="Current Period End" value={sub?.current_period_end ? new Date(sub.current_period_end as string).toLocaleDateString() : 'Not set'} />
<Row label="Cancel Scheduled" value={sub?.cancel_at ? new Date(sub.cancel_at as string).toLocaleDateString() : 'Not scheduled'} />
</Box>
<Box sx={{ p: 2, borderRadius: '12px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', fontWeight: 600, color: C.bright, mb: 1 }}>
Phase 1~15.5 Enabled Features
Entitlement source
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
<TactileBadge tone="mono" mono>{sub ? 'SUPABASE SUBSCRIPTION' : 'NO ACTIVE CONTRACT RECORD'}</TactileBadge>
</Box>
</Box>
</DoubleBezelCard>
</Box>
{/* Tier-based Enabled Features */}
<Box sx={{ p: 2, borderRadius: '12px', bgcolor: 'rgba(10, 17, 31, 0.7)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', fontWeight: 600, color: C.bright, mb: 1 }}>
Enabled Features
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
<TactileBadge tone="success" mono>Whisper Turbo STT (Unlimited)</TactileBadge>
@ -205,13 +213,11 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
{isProPlus && <TactileBadge tone="success" mono>Pyannote Diarization</TactileBadge>}
</Box>
</Box>
</DoubleBezelCard>
</Box>
{/* 30-Day Activity Heatmap Table */}
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 500, color: C.bright }}>
30-Day Feature Execution Telemetry
</Typography>
<TactileBadge tone="mono" mono>
@ -230,20 +236,26 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
</Box>
</Box>
<Box component="tbody">
{usage.map((row, i) => (
<Box component="tr" key={i}>
{usage.length === 0 ? (
<Box component="tr">
<Box component="td" colSpan={4} sx={{ textAlign: 'center', color: C.dim, py: 3 }}>
No measured usage in the last 30 days.
</Box>
</Box>
) : usage.map((row) => (
<Box component="tr" key={`${row.date as string}:${row.feature as string}`}>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>
{row.date as string}
</Box>
<Box component="td" sx={{ fontWeight: 600, color: C.bright }}>
{row.feature as string}
</Box>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight, fontWeight: 700 }}>
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight, fontWeight: 500 }}>
{row.count as number} calls
</Box>
<Box component="td">
<Box component="span" sx={statusBadgeSx('green')}>
SUCCESS
MEASURED
</Box>
</Box>
</Box>
@ -256,11 +268,11 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
{/* Payment History */}
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 500, color: C.bright }}>
Payment History & Invoices
</Typography>
<TactileBadge tone="success" mono>
LEMONSQUEEZY VERIFIED
PAYMENT DATA
</TactileBadge>
</Box>
<PaymentHistory userId={id} />

View file

@ -3,7 +3,8 @@
import React from 'react'
import { Box, Typography, Button } from '@mui/material'
import { fetchUsers } from '@/lib/api-server'
import { fetchProductUsers, isSupabaseAdminConfigured } from '@/lib/supabase-admin'
import { UnavailableAdminPanel } from '@/components/unavailable-admin-panel'
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, filterBtnSx, statusBadgeSx } from '@/lib/console-theme'
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
import Link from 'next/link'
@ -13,12 +14,25 @@ interface PageProps {
}
export default async function AdminUsersPage({ searchParams }: PageProps): Promise<React.ReactElement> {
if (!isSupabaseAdminConfigured()) {
return (
<UnavailableAdminPanel
title="User Directory & CRM"
capability="Accounts, tiers, roles, usage"
reason="SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 Supabase 사용자 디렉터리에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다."
/>
)
}
const params = await searchParams
const tierFilter = params.tier ?? 'all'
const roleFilter = params.role ?? 'all'
const searchQuery = (params.q ?? '').toLowerCase()
if (!['all', 'free', 'pro', 'pro_plus'].includes(tierFilter)) throw new Error('Invalid user tier filter')
if (!['all', 'user', 'manager', 'admin', 'super_admin'].includes(roleFilter)) throw new Error('Invalid user role filter')
const rawSearchQuery = params.q ?? ''
if (rawSearchQuery.length > 100) throw new Error('User search query is too long')
const searchQuery = rawSearchQuery.trim().toLowerCase()
const allUsers = await fetchUsers()
const allUsers = await fetchProductUsers()
const filteredUsers = allUsers.filter((u) => {
if (tierFilter !== 'all' && u.tier !== tierFilter) return false
@ -64,7 +78,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
sx={{
fontFamily: FONT_SANS,
fontSize: '20px',
fontWeight: 700,
fontWeight: 500,
color: C.bright,
letterSpacing: '-0.02em',
m: 0,
@ -85,7 +99,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
mt: 0.25,
}}
>
MULTI-TIER QUOTAS HARDWARE SESSIONS ROLES & ACCESS CONTROL
Supabase auth accounts, subscription tiers, roles and access control
</Typography>
</Box>
</Box>
@ -105,8 +119,8 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
{/* Main Table Card */}
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2.5 }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
User Account Profiles & Quota Consumption
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 500, color: C.bright }}>
User Account Profiles
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
SHOWING {filteredUsers.length} OF {allUsers.length} USERS
@ -120,8 +134,8 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
<Box component="th">USER PROFILE</Box>
<Box component="th">TIER</Box>
<Box component="th">ROLE</Box>
<Box component="th">DAILY QUOTA STATUS</Box>
<Box component="th">LAST ACTIVE PLATFORM</Box>
<Box component="th">USAGE</Box>
<Box component="th">LAST SIGN-IN</Box>
<Box component="th">STATUS</Box>
<Box component="th" sx={{ textAlign: 'right' }}>ACTION</Box>
</Box>
@ -157,7 +171,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
alignItems: 'center',
justifyContent: 'center',
color: '#ffffff',
fontWeight: 700,
fontWeight: 500,
fontSize: '13px',
boxShadow: isProPlus ? '0 0 12px rgba(168, 85, 247, 0.4)' : 'none',
}}
@ -169,7 +183,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
{u.name}
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
{u.email}
{u.email || 'Email not set'}
</Typography>
</Box>
</Box>
@ -179,7 +193,7 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
{/* Tier Badge */}
<Box component="td">
<Box component="span" sx={statusBadgeSx(isProPlus ? 'purple' : isPro ? 'green' : 'blue')}>
{isProPlus ? 'PRO+ VIP' : u.tier.toUpperCase()}
{isProPlus ? 'PRO+ VIP' : u.tier?.toUpperCase() ?? 'UNASSIGNED'}
</Box>
</Box>
@ -192,25 +206,14 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
{/* Daily Quota Status */}
<Box component="td">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', fontFamily: FONT_MONO }}>
<span style={{ color: C.dim }}>Dictations:</span>
<strong style={{ color: isProPlus || isPro ? C.green400 : C.bright }}>
{u.dailyUsage.dictations} / {u.dailyUsage.dictationsMax === 9999 ? '∞' : u.dailyUsage.dictationsMax}
</strong>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', fontFamily: FONT_MONO }}>
<span style={{ color: C.dim }}>LLM Calls:</span>
<strong style={{ color: C.bright }}>
{u.dailyUsage.llmCalls} / {u.dailyUsage.llmCallsMax === 9999 ? '∞' : u.dailyUsage.llmCallsMax}
</strong>
</Box>
</Box>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: C.dim }}>
Open user detail for measured usage
</Typography>
</Box>
{/* Last Active Platform */}
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.text }}>
{u.lastActiveDevice}
{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : 'Never signed in'}
</Box>
{/* Status */}

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 { checkRateLimit, recordFailedAttempt, resetFailedAttempts, signSession } from '@/lib/security'
import {
checkRateLimit,
recordFailedAttempt,
resetFailedAttempts,
signSession
} from '@/lib/security'
import { adminCookieSecure } from '@/lib/admin-session'
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'
export const runtime = 'nodejs'
interface BackendAuthResponse {
token?: unknown
email?: unknown
role?: unknown
expiresAt?: unknown
}
type AdminRole = 'manager' | 'admin' | 'super_admin'
function apiBase(): string {
const value = process.env.API_SERVER_URL?.trim()
if (!value) throw new Error('admin_auth_unavailable')
const parsed = new URL(value)
if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') {
throw new Error('admin_auth_unavailable')
}
return parsed.origin
}
function normalizeRole(value: unknown): AdminRole | null {
if (typeof value !== 'string') return null
const normalized = value.replace(/[_-]/g, '').toLowerCase()
if (normalized === 'superadmin') return 'super_admin'
if (normalized === 'admin') return 'admin'
if (normalized === 'manager') return 'manager'
return null
}
function clientIdentifier(request: Request, email: string): string {
const forwarded =
request.headers.get('x-forwarded-for') ?? request.headers.get('cf-connecting-ip') ?? 'unknown'
return `${forwarded.split(',')[0].trim().slice(0, 64)}:${email}`
}
function failure(key: string, status: number, retryAfterSeconds?: number): NextResponse {
return NextResponse.json(
{
success: false,
error: key,
...(retryAfterSeconds ? { retryAfter: retryAfterSeconds } : {})
},
{
status,
headers: retryAfterSeconds ? { 'Retry-After': String(retryAfterSeconds) } : undefined
}
)
}
export async function POST(request: Request): Promise<NextResponse> {
let body: unknown
try {
// 1. Extract Client IP & Identifier for Rate Limiting
const forwardedFor = request.headers.get('x-forwarded-for') || request.headers.get('cf-connecting-ip') || '127.0.0.1'
const clientIp = forwardedFor.split(',')[0].trim()
const body = await request.json()
const { usernameOrEmail, password, trap } = body
// 2. Honeypot Bot Trap: If hidden bot field is filled, silently reject
if (trap) {
await new Promise((resolve) => setTimeout(resolve, 1000))
return NextResponse.json({ success: false, message: 'Access denied' }, { status: 403 })
body = await request.json()
} catch {
return failure('invalid_request', 400)
}
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return failure('invalid_request', 400)
}
if (!usernameOrEmail || !password) {
return NextResponse.json(
{ success: false, message: '아이디와 비밀번호를 입력해주세요.' },
{ status: 400 }
)
const candidate = body as Record<string, unknown>
if (candidate.trap) return failure('access_denied', 403)
const email =
typeof candidate.usernameOrEmail === 'string'
? candidate.usernameOrEmail.trim().toLowerCase()
: ''
const password = typeof candidate.password === 'string' ? candidate.password : ''
if (!email || email.length > 150 || !password || password.length > 256) {
return failure('invalid_request', 400)
}
const trimmedUser = String(usernameOrEmail).trim().toLowerCase()
const trimmedPass = String(password).trim()
const rateLimitKey = `${clientIp}:${trimmedUser}`
// 3. Check Sliding-Window Rate Limiter
const rateLimitKey = clientIdentifier(request, email)
const rateCheck = checkRateLimit(rateLimitKey)
if (!rateCheck.allowed) {
return NextResponse.json(
{
success: false,
message: `로그인 시도 횟수를 초과했습니다. 보안을 위해 ${rateCheck.retryAfterSeconds}초 후 다시 시도해주세요.`,
retryAfter: rateCheck.retryAfterSeconds,
},
{
status: 429,
headers: { 'Retry-After': String(rateCheck.retryAfterSeconds) },
}
)
return failure('rate_limited', 429, rateCheck.retryAfterSeconds)
}
let authenticated = false
let userRole = 'super_admin'
let userEmail = 'admin@d3ro.voice'
let token = ''
// 4. Master Admin Verification (admin / Test1234!)
if (
(trimmedUser === 'admin' || trimmedUser === 'admin@d3ro.voice' || trimmedUser === 'admin@d3ro.dev') &&
trimmedPass === 'Test1234!'
) {
authenticated = true
userRole = 'super_admin'
userEmail = 'admin@d3ro.voice'
token = `d3ro_tok_${Date.now()}`
}
// 5. Backend C# API Verification
if (!authenticated) {
let backend: BackendAuthResponse
try {
const apiRes = await fetch(`${API_BASE}/api/auth/login`, {
const response = await fetch(`${apiBase()}/api/auth/login`, {
method: 'POST',
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 (apiRes.ok) {
const data = await apiRes.json()
if (data.token) {
authenticated = true
token = data.token
userEmail = data.email || trimmedUser
userRole = (data.role || '').toLowerCase() === 'admin' ? 'admin' : 'super_admin'
}
}
if (!response.ok) throw new Error('invalid_credentials')
backend = (await response.json()) as BackendAuthResponse
} 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)
if (!authenticated) {
const lockResult = recordFailedAttempt(rateLimitKey)
// Dynamic delay to prevent timing analysis
await new Promise((resolve) => setTimeout(resolve, 600))
if (lockResult.locked) {
return NextResponse.json(
{
success: false,
message: `5회 이상 잘못된 비밀번호가 입력되었습니다. 보안을 위해 계정이 ${lockResult.retryAfterSeconds}초 동안 잠깁니다.`,
retryAfter: lockResult.retryAfterSeconds,
},
{
status: 429,
headers: { 'Retry-After': String(lockResult.retryAfterSeconds) },
}
)
const role = normalizeRole(backend.role)
const token = typeof backend.token === 'string' ? backend.token : ''
const authenticatedEmail =
typeof backend.email === 'string' ? backend.email.trim().toLowerCase() : ''
const backendExpiry =
typeof backend.expiresAt === 'string' ? Date.parse(backend.expiresAt) : Number.NaN
if (
!role ||
token.length < 80 ||
token.length > 8_192 ||
!authenticatedEmail ||
authenticatedEmail !== email ||
!Number.isFinite(backendExpiry) ||
backendExpiry <= Date.now()
) {
return failure('invalid_auth_response', 502)
}
return NextResponse.json(
{ success: false, message: '아이디 또는 비밀번호가 올바르지 않습니다.' },
{ status: 401 }
)
}
// 7. Successful Authentication -> Clear Rate Limiting
resetFailedAttempts(rateLimitKey)
// 8. Generate Cryptographically Signed HMAC Session Token
const sessionData = {
id: 'admin-usr-1',
username: 'admin',
email: userEmail,
role: userRole,
const expiresAt = Math.min(backendExpiry, Date.now() + 8 * 60 * 60 * 1_000)
const signedCookieValue = signSession({
email: authenticatedEmail,
role,
token,
loginAt: new Date().toISOString(),
expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days
}
const signedCookieValue = signSession(sessionData)
expiresAt
})
const response = NextResponse.json({
success: true,
user: {
id: sessionData.id,
email: sessionData.email,
role: sessionData.role,
},
user: { email: authenticatedEmail, role }
})
// 9. Set Hardened HttpOnly SameSite=Strict Cookie
response.cookies.set({
name: 'd3ro_admin_session',
value: signedCookieValue,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
secure: adminCookieSecure(),
sameSite: 'strict',
path: '/',
maxAge: 30 * 24 * 60 * 60,
maxAge: Math.max(1, Math.floor((expiresAt - Date.now()) / 1_000))
})
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
import { NextResponse } from 'next/server'
import { adminCookieSecure } from '@/lib/admin-session'
export async function POST(): Promise<NextResponse> {
const response = NextResponse.json({ success: true, message: 'Logged out successfully' })
@ -10,7 +11,7 @@ export async function POST(): Promise<NextResponse> {
name: 'd3ro_admin_session',
value: '',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
secure: adminCookieSecure(),
sameSite: 'lax',
path: '/',
maxAge: 0,
@ -28,7 +29,7 @@ export async function GET(request: Request): Promise<NextResponse> {
name: 'd3ro_admin_session',
value: '',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
secure: adminCookieSecure(),
sameSite: 'lax',
path: '/',
maxAge: 0,

View file

@ -1,36 +1,21 @@
/* D3RO Voice Admin CRM — "Midnight Glass v2" Global Styles */
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap');
:root {
--d3-bg-base: #070b16;
--d3-bg-app: #0a0e1c;
--d3-bg-card: #111a30;
--d3-bg-card-hover: #152039;
--d3-bg-elevated: #1a2540;
--d3-bg-input: #0d1526;
--d3-bg-sidebar: #0b101f;
--d3-border: rgba(148, 180, 255, 0.08);
--d3-border-hl: rgba(148, 180, 255, 0.16);
--d3-accent: #3b82f6;
--d3-accent-light: #60a5fa;
--d3-cyan: #06b6d4;
--d3-purple: #8b5cf6;
--text-dim: #67789e;
--text-base: #93a4c8;
--text-bright: #eef2fb;
}
/* 폰트는 layout.tsx <head> Pretendard Variable 다이나믹 서브셋 링크로 로드한다
(렌더를 막는 @import 이중 로드는 제거).
변수는 @d3ro/ui SSOT가 MuiCssBaseline로 주입하는 --d3-* 그대로 쓴다
(관리자는 고정 다크: 미지정 아래 폴백). */
* {
box-sizing: border-box;
}
/* 의미적 강조는 세미볼드(600)까지 — 볼드(700+) 타이포그래피 금지 */
strong, b { font-weight: 600; }
body {
margin: 0;
padding: 0;
background-color: var(--d3-bg-base);
color: var(--text-bright);
background-color: var(--d3-bg-app, #070b16);
color: var(--d3-text-primary, #eef2fb);
font-family: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;

View file

@ -21,10 +21,10 @@ export default function RootLayout({
<head>
<title>D3RO Voice Admin & Intelligence CRM</title>
<meta name="description" content="D3RO Voice AI Voice Assistant Administration Console" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css" />
{/* Pretendard Variable ( ).
mono는 (ui-monospace) . */}
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossOrigin="" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css" />
</head>
<body suppressHydrationWarning>
<div className="admin-ambient-glow" />

View file

@ -17,15 +17,33 @@ function LoginForm(): React.ReactElement {
const searchParams = useSearchParams()
const redirectPath = searchParams.get('redirect') || '/'
const [usernameOrEmail, setUsernameOrEmail] = useState('admin')
const [password, setPassword] = useState('Test1234!')
const [usernameOrEmail, setUsernameOrEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const describeLoginError = (errorKey: unknown, retryAfter: unknown): string => {
if (errorKey === 'rate_limited') {
const seconds = typeof retryAfter === 'number' && retryAfter > 0 ? retryAfter : null
return seconds
? `로그인 시도가 제한되었습니다. ${Math.ceil(seconds / 60)}분 후 다시 시도해주세요.`
: '로그인 시도가 제한되었습니다. 잠시 후 다시 시도해주세요.'
}
if (errorKey === 'invalid_credentials') return '이메일 또는 비밀번호가 올바르지 않습니다.'
if (errorKey === 'invalid_auth_response' || errorKey === 'admin_auth_unavailable') {
return '인증 서버 응답이 올바르지 않습니다. 관리자에게 문의해주세요.'
}
return '인증에 실패했습니다.'
}
const handleLogin = async (e?: React.FormEvent): Promise<void> => {
if (e) e.preventDefault()
if (!usernameOrEmail || !password) {
setErrorMsg('아이디와 비밀번호를 입력해주세요.')
setErrorMsg('이메일과 비밀번호를 입력해주세요.')
return
}
if (!usernameOrEmail.includes('@')) {
setErrorMsg('관리자 이메일 주소로 로그인해주세요. (username 로그인은 지원이 종료되었습니다)')
return
}
@ -42,7 +60,7 @@ function LoginForm(): React.ReactElement {
const data = await res.json()
if (!res.ok || !data.success) {
throw new Error(data.message || '인증에 실패했습니다.')
throw new Error(describeLoginError(data.error, data.retryAfter))
}
// Successful login
@ -117,7 +135,7 @@ function LoginForm(): React.ReactElement {
sx={{
fontFamily: FONT_SANS,
fontSize: '22px',
fontWeight: 800,
fontWeight: 600,
letterSpacing: '-0.02em',
color: C.bright,
lineHeight: 1.2,
@ -165,13 +183,14 @@ function LoginForm(): React.ReactElement {
{/* Login Form */}
<Box component="form" onSubmit={handleLogin} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Admin Identifier (Username / Email)"
label="Admin Email"
type="email"
value={usernameOrEmail}
onChange={(e) => setUsernameOrEmail(e.target.value)}
fullWidth
size="small"
required
autoComplete="username"
autoComplete="email"
sx={{
'& .MuiOutlinedInput-root': {
bgcolor: 'rgba(10, 17, 31, 0.8)',

View file

@ -39,7 +39,7 @@ export default function UnauthorizedPage(): React.ReactElement {
sx={{
fontFamily: FONT_SANS,
fontSize: '22px',
fontWeight: 800,
fontWeight: 600,
color: C.bright,
mb: 1,
}}

View file

@ -14,8 +14,6 @@ interface NavGroup {
key: string
path: string
label: string
badge?: string
badgeColor?: 'blue' | 'purple' | 'green' | 'orange'
icon: React.ReactElement
}>
}
@ -38,8 +36,6 @@ const NAV_GROUPS: NavGroup[] = [
key: 'pipelines',
path: '/pipelines',
label: 'AI & Voice Pipelines',
badge: 'v0.2',
badgeColor: 'blue',
icon: (
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 100-6 3 3 0 000 6z" />
@ -60,8 +56,6 @@ const NAV_GROUPS: NavGroup[] = [
key: 'releases',
path: '/releases',
label: 'Release & Downloads',
badge: 'v1.0',
badgeColor: 'green',
icon: (
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
@ -77,8 +71,6 @@ const NAV_GROUPS: NavGroup[] = [
key: 'users',
path: '/users',
label: 'User Directory',
badge: '4.5k',
badgeColor: 'purple',
icon: (
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
@ -99,8 +91,6 @@ const NAV_GROUPS: NavGroup[] = [
key: 'ads',
path: '/ads',
label: 'Ad Monetization',
badge: '$4.6k',
badgeColor: 'blue',
icon: (
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
@ -111,8 +101,6 @@ const NAV_GROUPS: NavGroup[] = [
key: 'support',
path: '/support',
label: 'Customer Support (CA)',
badge: '4 Live',
badgeColor: 'orange',
icon: (
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z" />
@ -148,7 +136,9 @@ const NAV_GROUPS: NavGroup[] = [
},
]
export function AdminSidebar(): React.ReactElement {
export function AdminSidebar({ identity }: {
identity: { email: string; role: 'manager' | 'admin' | 'super_admin' }
}): React.ReactElement {
const pathname = usePathname()
const router = useRouter()
@ -239,7 +229,7 @@ export function AdminSidebar(): React.ReactElement {
sx={{
fontFamily: FONT_SANS,
fontSize: '15px',
fontWeight: 700,
fontWeight: 500,
letterSpacing: '-0.02em',
color: C.bright,
lineHeight: 1.2,
@ -261,7 +251,7 @@ export function AdminSidebar(): React.ReactElement {
</Box>
</Box>
{/* Live Node Pulse */}
{/* Verified local session indicator */}
<Box
sx={{
display: 'flex',
@ -285,7 +275,7 @@ export function AdminSidebar(): React.ReactElement {
}}
/>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '9px', fontWeight: 600, color: '#34d399' }}>
LIVE
SESSION
</Typography>
</Box>
</Box>
@ -377,63 +367,12 @@ export function AdminSidebar(): React.ReactElement {
</Typography>
</Box>
{item.badge && (
<Box
sx={{
px: 0.9,
py: 0.2,
borderRadius: '999px',
fontSize: '10px',
fontFamily: FONT_MONO,
fontWeight: 700,
bgcolor: item.badgeColor === 'blue' ? 'rgba(59, 130, 246, 0.2)' : 'rgba(139, 92, 246, 0.2)',
color: item.badgeColor === 'blue' ? C.cyanLight : C.purple400,
border: `1px solid ${item.badgeColor === 'blue' ? 'rgba(59, 130, 246, 0.3)' : 'rgba(139, 92, 246, 0.3)'}`,
}}
>
{item.badge}
</Box>
)}
</Box>
)
})}
</Box>
</Box>
))}
{/* Live Service Matrix Mini-Widget */}
<Box
sx={{
mt: 1,
p: 2,
borderRadius: '14px',
bgcolor: 'rgba(13, 21, 38, 0.7)',
border: `1px solid ${C.border}`,
display: 'flex',
flexDirection: 'column',
gap: 1.2,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', fontWeight: 600, color: C.dim, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Service Telemetry
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '10px', color: '#10b981' }}>
99.9% Up
</Typography>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1 }}>
<Box sx={{ p: 1, borderRadius: '8px', bgcolor: 'rgba(17, 26, 48, 0.6)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '9px', color: C.dim }}>STT LATENCY</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', fontWeight: 700, color: C.cyanLight }}>142ms</Typography>
</Box>
<Box sx={{ p: 1, borderRadius: '8px', bgcolor: 'rgba(17, 26, 48, 0.6)', border: `1px solid ${C.border}` }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '9px', color: C.dim }}>OLLAMA VRAM</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '12px', fontWeight: 700, color: C.purple400 }}>4.6 GB</Typography>
</Box>
</Box>
</Box>
</Box>
{/* User Footer */}
@ -471,20 +410,20 @@ export function AdminSidebar(): React.ReactElement {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontWeight: 500,
fontSize: '13px',
color: '#ffffff',
boxShadow: '0 0 12px rgba(59, 130, 246, 0.4)',
}}
>
A
{identity.email.slice(0, 1).toUpperCase()}
</Box>
<Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', fontWeight: 600, color: C.bright, lineHeight: 1.2 }}>
Admin User
{identity.email}
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '9px', fontWeight: 600, color: C.purple400, letterSpacing: '0.04em' }}>
SUPER_ADMIN
{identity.role.toUpperCase()}
</Typography>
</Box>
</Box>
@ -516,4 +455,3 @@ export function AdminSidebar(): React.ReactElement {
</Box>
)
}

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'
// apps/admin/src/components/license-issuer-button.tsx
// D3RO Voice Admin — Ed25519 라이선스 발급 트리거 버튼
// D3RO Voice Admin — Ed25519 라이선스 발급 트리거 버튼 (super_admin 전용)
import { useState } from 'react'
import { Button } from '@mui/material'
import { primaryButtonSx } from '@/lib/console-theme'
import { C, primaryButtonSx } from '@/lib/console-theme'
import { LicenseIssuerDialog } from './license-issuer-dialog'
export function LicenseIssuerButton(): React.ReactElement {
@ -19,8 +19,8 @@ export function LicenseIssuerButton(): React.ReactElement {
onClick={() => setOpen(true)}
sx={{
...primaryButtonSx,
background: 'linear-gradient(135deg, #a855f7 0%, #3b82f6 100%)',
boxShadow: '0 0 12px rgba(168, 85, 247, 0.4)',
background: `linear-gradient(135deg, ${C.purple} 0%, ${C.accent} 100%)`,
boxShadow: '0 0 12px rgba(139, 92, 246, 0.4)'
}}
>
Issue Ed25519 License Key

View file

@ -1,7 +1,7 @@
'use client'
// apps/admin/src/components/license-issuer-dialog.tsx
// D3RO Voice Admin — Ed25519 비대칭 암호화 라이선스 발급 다이얼로그
// Ed25519 라이선스 발급 다이얼로그 — 서명은 서버(/api/admin/license)에서만 수행한다.
import { useState } from 'react'
import {
@ -17,12 +17,8 @@ import {
Select,
MenuItem,
Button,
Alert,
Alert
} from '@mui/material'
import {
issueSignedLicenseKey,
DEFAULT_LICENSE_PRIVATE_KEY,
} from '@d3ro/core/utils/crypto-license'
import type { LicenseTier } from '@d3ro/core/types'
import { d3roPalette, d3roFontMono, d3roRadius } from '@d3ro/ui/theme'
import { C, FONT_SANS, FONT_MONO, primaryButtonSx } from '@/lib/console-theme'
@ -32,6 +28,16 @@ interface LicenseIssuerDialogProps {
onClose: () => void
}
function describeIssueError(errorKey: unknown): string {
if (errorKey === 'license_signing_unavailable') {
return '서명 키가 구성되지 않았습니다. ADMIN_LICENSE_PRIVATE_KEY 환경변수를 설정해주세요.'
}
if (errorKey === 'admin_forbidden') return 'super_admin 권한이 필요한 작업입니다.'
if (errorKey === 'admin_session_invalid') return '세션이 만료되었습니다. 다시 로그인해주세요.'
if (errorKey === 'invalid_customer_email') return '고객 이메일 형식이 올바르지 않습니다.'
return '라이선스 발급에 실패했습니다.'
}
export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps): React.ReactElement {
const [customerEmail, setCustomerEmail] = useState('')
const [tier, setTier] = useState<LicenseTier>('pro_plus')
@ -39,10 +45,13 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
const [machineId, setMachineId] = useState('')
const [teamId, setTeamId] = useState('')
const [generatedKey, setGeneratedKey] = useState<string | null>(null)
const [usedDefaultKey, setUsedDefaultKey] = useState(false)
const [auditRecorded, setAuditRecorded] = useState(true)
const [loading, setLoading] = useState(false)
const [copied, setCopied] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleGenerate = () => {
const handleGenerate = async (): Promise<void> => {
setError(null)
setCopied(false)
if (!customerEmail.trim()) {
@ -50,36 +59,36 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
return
}
setLoading(true)
try {
const now = Date.now()
let expiresAt: number | null = null
if (validity === '30d') {
expiresAt = now + 30 * 24 * 60 * 60 * 1000
} else if (validity === '365d') {
expiresAt = now + 365 * 24 * 60 * 60 * 1000
}
const payload = {
licenseId: `lic-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 6)}`,
tier,
const response = await fetch('/api/admin/license', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customerEmail: customerEmail.trim(),
issuedAt: now,
expiresAt,
machineId: machineId.trim() || null,
teamId: teamId.trim() || undefined,
maxDevices: tier === 'enterprise' ? 999 : tier === 'team' ? 25 : tier === 'pro_plus' ? 5 : 3,
tier,
validity,
machineId: machineId.trim(),
teamId: teamId.trim()
})
})
const data = (await response.json()) as Record<string, unknown>
if (!response.ok || data.success !== true || typeof data.licenseKey !== 'string') {
throw new Error(describeIssueError(data.error))
}
const key = issueSignedLicenseKey(payload, DEFAULT_LICENSE_PRIVATE_KEY)
setGeneratedKey(key)
setGeneratedKey(data.licenseKey)
setUsedDefaultKey(data.usedDefaultKey === true)
setAuditRecorded(data.auditRecorded !== false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate license key')
} finally {
setLoading(false)
}
}
const handleCopy = () => {
const handleCopy = (): void => {
if (!generatedKey) return
navigator.clipboard.writeText(generatedKey)
void navigator.clipboard.writeText(generatedKey)
setCopied(true)
setTimeout(() => setCopied(false), 3000)
}
@ -90,13 +99,13 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
fontSize: 13,
color: d3roPalette.text.primary,
bgcolor: d3roPalette.bg.inset,
borderRadius: d3roRadius.button,
borderRadius: d3roRadius.button
},
'& .MuiInputLabel-root': {
fontFamily: FONT_MONO,
fontSize: 12,
color: C.dim,
},
color: C.dim
}
}
return (
@ -107,21 +116,21 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
fullWidth
PaperProps={{
sx: {
bgcolor: '#0a0d14',
border: '1px solid rgba(255, 255, 255, 0.1)',
bgcolor: C.app,
border: `1px solid ${C.borderHl}`,
borderRadius: '16px',
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.8)',
p: 1,
},
p: 1
}
}}
>
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', pb: 1 }}>
<Box>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '18px', fontWeight: 700, color: C.bright }}>
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '18px', fontWeight: 600, color: C.bright }}>
Issue Cryptographic License Key
</Typography>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
ED25519 ASYMMETRIC SIGNED OFFLINE / ENTERPRISE TOKEN
ED25519 SERVER-SIGNED OFFLINE / ENTERPRISE TOKEN
</Typography>
</Box>
<IconButton onClick={onClose} size="small" sx={{ color: C.dim }}>
@ -152,7 +161,11 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
<FormControl fullWidth sx={inputSx}>
<InputLabel>Validity Period</InputLabel>
<Select value={validity} onChange={(e) => setValidity(e.target.value as '30d' | '365d' | 'lifetime')} label="Validity Period">
<Select
value={validity}
onChange={(e) => setValidity(e.target.value as '30d' | '365d' | 'lifetime')}
label="Validity Period"
>
<MenuItem value="30d">30 Days (Monthly)</MenuItem>
<MenuItem value="365d">1 Year (Annual)</MenuItem>
<MenuItem value="lifetime">Lifetime (Permanent)</MenuItem>
@ -184,20 +197,32 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
<Button
variant="contained"
onClick={handleGenerate}
onClick={() => void handleGenerate()}
disabled={loading}
sx={{
...primaryButtonSx,
py: 1.2,
background: 'linear-gradient(135deg, #a855f7 0%, #3b82f6 100%)',
fontWeight: 700,
background: `linear-gradient(135deg, ${C.purple} 0%, ${C.accent} 100%)`,
fontWeight: 600
}}
>
Generate Ed25519 Signed License
{loading ? 'Signing…' : '⚡ Generate Ed25519 Signed License'}
</Button>
{generatedKey && (
<Box sx={{ mt: 1, p: 2, bgcolor: 'rgba(0, 0, 0, 0.4)', borderRadius: '10px', border: '1px solid rgba(168, 85, 247, 0.4)' }}>
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: '#a855f7', fontWeight: 700, mb: 0.5 }}>
<Box sx={{ mt: 1, p: 2, bgcolor: 'rgba(0, 0, 0, 0.4)', borderRadius: '10px', border: `1px solid ${C.borderStrong}` }}>
{usedDefaultKey && (
<Alert severity="warning" sx={{ fontFamily: FONT_MONO, fontSize: '11px', mb: 1.5 }}>
.
ADMIN_LICENSE_PRIVATE_KEY로 .
</Alert>
)}
{!auditRecorded && (
<Alert severity="info" sx={{ fontFamily: FONT_MONO, fontSize: '11px', mb: 1.5 }}>
. .
</Alert>
)}
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.purple400, fontWeight: 600, mb: 0.5 }}>
SIGNED LICENSE KEY (Copy and paste into D3RO Voice Desktop App):
</Typography>
<Typography
@ -210,7 +235,7 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
borderRadius: '6px',
wordBreak: 'break-all',
userSelect: 'all',
mb: 1.5,
mb: 1.5
}}
>
{generatedKey}
@ -222,8 +247,8 @@ export function LicenseIssuerDialog({ open, onClose }: LicenseIssuerDialogProps)
sx={{
fontFamily: FONT_MONO,
fontSize: '12px',
borderColor: copied ? '#10b981' : '#a855f7',
color: copied ? '#10b981' : C.bright,
borderColor: copied ? C.green : C.purple400,
color: copied ? C.green400 : C.bright
}}
>
{copied ? '✓ Copied to Clipboard!' : '📋 Copy License Key'}

View file

@ -4,7 +4,7 @@
// 결제 이력 패널 — DB + Payple 조회
import { useState, useEffect } from 'react'
import { Box, Button, CircularProgress } from '@mui/material'
import { Box, CircularProgress } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { callAdminApi } from '@/lib/admin-api'
@ -25,22 +25,23 @@ interface AuditLogEntry {
interface PaymentData {
subscription: Record<string, unknown> | null
auditLogs: AuditLogEntry[]
paypleHistory?: Record<string, unknown>
paypleError?: string
providerEvents: Array<Record<string, unknown>>
providerOperations: Array<Record<string, unknown>>
liveProviderHistoryAvailable: false
}
export function PaymentHistory({ userId }: PaymentHistoryProps): React.ReactElement {
const [data, setData] = useState<PaymentData | null>(null)
const [loading, setLoading] = useState(true)
const [paypleLoading, setPaypleLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const load = async (): Promise<void> => {
try {
const result = await callAdminApi<PaymentData>(`admin-payments?userId=${userId}`)
setData(result)
} catch {
// ignore
} catch (caught) {
setError(caught instanceof Error ? caught.message : 'Failed to load payment data')
} finally {
setLoading(false)
}
@ -48,18 +49,6 @@ export function PaymentHistory({ userId }: PaymentHistoryProps): React.ReactElem
void load()
}, [userId])
const loadPayple = async (): Promise<void> => {
setPaypleLoading(true)
try {
const result = await callAdminApi<PaymentData>(`admin-payments?userId=${userId}&source=payple`)
setData(result)
} catch {
// ignore
} finally {
setPaypleLoading(false)
}
}
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
@ -69,55 +58,56 @@ export function PaymentHistory({ userId }: PaymentHistoryProps): React.ReactElem
}
if (!data) {
return <PhosphorText variant="dim">Failed to load payment data</PhosphorText>
return <PhosphorText variant="dim" sx={{ color: d3roPalette.tag.red }}>{error ?? 'Failed to load payment data'}</PhosphorText>
}
const sub = data.subscription
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{error && <PhosphorText variant="dim" sx={{ color: d3roPalette.tag.red }}>{error}</PhosphorText>}
{/* Subscription summary */}
{sub && (
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PAYMENT INFO</PhosphorText>
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Row label="PROVIDER" value={((sub.payment_provider as string) ?? 'none').toUpperCase()} />
<Row label="PAYPLE PAYER ID" value={(sub.payple_payer_id as string) ?? '-'} />
<Row label="PAYPLE OID" value={(sub.payple_pay_oid as string) ?? '-'} />
<Row label="RENEWAL FAILURES" value={String(sub.renewal_failures ?? 0)} />
<Row label="PROVIDER" value={String(sub.provider ?? sub.payment_provider).toUpperCase()} />
<Row label="TIER" value={String(sub.tier).toUpperCase()} />
<Row label="STATUS" value={String(sub.status).toUpperCase()} />
<Row label="CURRENT PERIOD END" value={typeof sub.current_period_end === 'string' ? new Date(sub.current_period_end).toLocaleString() : 'Not set'} />
</Box>
</Box>
</MetalCard>
)}
{/* Payple direct query */}
{/* Provider ledger. Provider payloads and secret identifiers are intentionally excluded. */}
<MetalCard>
<Box sx={{ p: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<PhosphorText variant="label">PAYPLE HISTORY</PhosphorText>
<Button
size="small"
onClick={() => void loadPayple()}
disabled={paypleLoading}
sx={{ fontFamily: d3roFontMono, fontSize: 11 }}
>
{paypleLoading ? 'Loading...' : 'Fetch from Payple'}
</Button>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PAYMENT PROVIDER EVENT LEDGER</PhosphorText>
{data.providerEvents.length === 0 ? (
<PhosphorText variant="dim">No provider events recorded</PhosphorText>
) : data.providerEvents.map((event) => (
<Box key={String(event.id)} sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, py: 0.75, borderBottom: `1px solid ${d3roPalette.border.subtle}` }}>
{String(event.provider).toUpperCase()} · {String(event.event_type)} · {String(event.disposition).toUpperCase()} · {new Date(String(event.event_created_at)).toLocaleString()}
</Box>
{data.paypleHistory ? (
<Box sx={{
fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
bgcolor: d3roPalette.bg.inset, borderRadius: 1, p: 1, maxHeight: 300, overflow: 'auto',
whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: d3roPalette.text.primary,
}}>
{JSON.stringify(data.paypleHistory, null, 2)}
))}
<PhosphorText variant="dim" sx={{ display: 'block', mt: 1 }}>
Live provider lookup is not connected. Raw Payple responses and provider identifiers are never exposed here.
</PhosphorText>
</Box>
) : data.paypleError ? (
<PhosphorText variant="dim" sx={{ color: d3roPalette.tag.red }}>{data.paypleError}</PhosphorText>
) : (
<PhosphorText variant="dim">Click &quot;Fetch from Payple&quot; to query payment history</PhosphorText>
)}
</MetalCard>
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PAYMENT OPERATIONS</PhosphorText>
{data.providerOperations.length === 0 ? (
<PhosphorText variant="dim">No payment operations recorded</PhosphorText>
) : data.providerOperations.map((operation) => (
<Box key={String(operation.id)} sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, py: 0.75, borderBottom: `1px solid ${d3roPalette.border.subtle}` }}>
{String(operation.provider).toUpperCase()} · {String(operation.operation_type)} · {String(operation.state).toUpperCase()} · {new Date(String(operation.created_at)).toLocaleString()}
</Box>
))}
</Box>
</MetalCard>

View file

@ -77,8 +77,10 @@ export function RoleChangeDialog({
</PhosphorText>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.label }}>New Role</InputLabel>
<InputLabel id="role-change-new-role-label" sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.label }}>New Role</InputLabel>
<Select
id="role-change-new-role"
labelId="role-change-new-role-label"
value={newRole}
onChange={(e) => setNewRole(e.target.value as Role)}
label="New Role"

View file

@ -18,7 +18,7 @@ import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
import { callAdminApi } from '@/lib/admin-api'
type Tier = 'free' | 'pro' | 'pro_plus' | 'team' | 'enterprise'
type Tier = 'free' | 'pro' | 'pro_plus'
type SubStatus = 'active' | 'canceled' | 'past_due' | 'expired'
interface SubscriptionFormProps {
@ -104,19 +104,17 @@ export function SubscriptionForm({ mode, userId, initial, onSuccess }: Subscript
</PhosphorText>
<FormControl fullWidth sx={inputSx}>
<InputLabel sx={{ fontFamily: d3roFontMono }}>Tier</InputLabel>
<Select value={tier} onChange={(e) => setTier(e.target.value as Tier)} label="Tier">
<InputLabel id="subscription-tier-label" sx={{ fontFamily: d3roFontMono }}>Tier</InputLabel>
<Select id="subscription-tier" labelId="subscription-tier-label" value={tier} onChange={(e) => setTier(e.target.value as Tier)} label="Tier">
<MenuItem value="free">FREE</MenuItem>
<MenuItem value="pro">PRO</MenuItem>
<MenuItem value="pro_plus">PRO+</MenuItem>
<MenuItem value="team">TEAM</MenuItem>
<MenuItem value="enterprise">ENTERPRISE</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth sx={inputSx}>
<InputLabel sx={{ fontFamily: d3roFontMono }}>Status</InputLabel>
<Select value={status} onChange={(e) => setStatus(e.target.value as SubStatus)} label="Status">
<InputLabel id="subscription-status-label" sx={{ fontFamily: d3roFontMono }}>Status</InputLabel>
<Select id="subscription-status" labelId="subscription-status-label" value={status} onChange={(e) => setStatus(e.target.value as SubStatus)} label="Status">
<MenuItem value="active">ACTIVE</MenuItem>
<MenuItem value="canceled">CANCELED</MenuItem>
<MenuItem value="past_due">PAST DUE</MenuItem>

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
// Edge Function 호출 헬퍼 — 클라이언트 컴포넌트용
import { getSupabaseBrowserClient } from './supabase-browser'
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
'use client'
interface AdminApiOptions extends Omit<RequestInit, 'headers'> {
headers?: Record<string, string>
@ -13,33 +8,25 @@ export async function callAdminApi<T = Record<string, unknown>>(
path: string,
options: AdminApiOptions = {}
): Promise<T> {
const supabase = getSupabaseBrowserClient()
// getUser()로 토큰 갱신을 트리거한 뒤 세션에서 access_token 획득
const { data: { user }, error: userError } = await supabase.auth.getUser()
if (userError || !user) {
throw new Error('Not authenticated — please re-login')
const [operation, query = ''] = path.split('?', 2)
if (!/^admin-[a-z-]+$/.test(operation)) throw new Error('Invalid admin operation')
const method = (options.method ?? 'GET').toUpperCase()
const headers: Record<string, string> = {
Accept: 'application/json',
...(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()
if (!session?.access_token) {
throw new Error('Not authenticated — session expired')
}
const response = await fetch(`${SUPABASE_URL}/functions/v1/${path}`, {
const response = await fetch(`/api/admin/supabase/${operation}${query ? `?${query}` : ''}`, {
...options,
headers: {
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
...options.headers,
},
method,
headers,
cache: 'no-store'
})
const data = await response.json() as T & { error?: string }
if (!response.ok) {
throw new Error(data.error ?? `API error: ${response.status}`)
}
return data
const payload = await response.json().catch(() => null) as T & { error?: string }
if (!response.ok) throw new Error(payload?.error ?? `Admin API error (${response.status})`)
return payload
}

View file

@ -3,8 +3,10 @@
import { cookies } from 'next/headers'
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 {
id: string
@ -22,20 +24,16 @@ export async function requireManager(): Promise<AdminUser> {
redirect('/login')
}
try {
const decoded = JSON.parse(Buffer.from(sessionCookie, 'base64').toString('utf-8'))
if (!decoded || !decoded.expiresAt || decoded.expiresAt <= Date.now()) {
const session = verifySession(sessionCookie)
if (!session) {
redirect('/login')
}
return {
id: decoded.id || 'admin-usr-1',
email: decoded.email || 'admin@d3ro.voice',
name: decoded.username === 'admin' ? 'Master Admin' : decoded.email,
role: (decoded.role as AdminRole) || 'super_admin',
}
} catch {
redirect('/login')
id: session.email,
email: session.email,
name: session.email,
role: session.role
}
}

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
// Helper library for connecting Next.js apps/admin to C# .NET API Backend & High-Fidelity D3RO Telemetry
import 'server-only'
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'
import { fetchAdminBackend } from './backend-session'
export interface SystemNodeHealth {
id: string
@ -16,44 +15,24 @@ export interface SystemNodeHealth {
}
export interface PipelineStats {
whisper: {
engine: string
activeModel: string
whisper: { engine: string; activeModel: string; 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 FeatureUsageBreakdown {
featureId: string
featureName: string
category: string
totalCalls?: number
callCount?: number
percentage?: number
tokensUsed: number
totalCost: number
estimatedCostUsd?: 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 {
@ -63,44 +42,27 @@ export interface ServerStats {
totalCost: number
serverUptimeSeconds: number
errorCount: number
arrUsd: number
mrrUsd: number
tierDistribution: {
free: number
pro: number
pro_plus: number
}
arrUsd: number | null
mrrUsd: number | null
tierDistribution: { free: number; pro: number; pro_plus: number } | null
nodes: SystemNodeHealth[]
pipelines: PipelineStats
pipelines: PipelineStats | null
featureBreakdown: FeatureUsageBreakdown[]
recentErrors: Array<{
id: number
errorType: string
message: string
endpoint: string | null
createdAt: string
}>
recentErrors: Array<{ id: number; errorType: string; message: string; endpoint: string | null; createdAt: string }>
}
export interface UserItem {
id: number
id: string
uid: string
email: string
name: string
role: 'user' | 'manager' | 'admin' | 'super_admin'
tier: 'free' | 'pro' | 'pro_plus'
tier: 'free' | 'pro' | 'pro_plus' | null
createdAt: string
lastLoginAt: string | null
lastActiveDevice: string
lastActiveDevice: string | null
isActive: boolean
dailyUsage: {
dictations: number
dictationsMax: number
llmCalls: number
llmCallsMax: number
ragQueries: number
ragQueriesMax: number
}
dailyUsage: { dictations: number; dictationsMax: number | null; llmCalls: number; llmCallsMax: number | null; ragQueries: number; ragQueriesMax: number | null } | null
}
export interface ModelEndpoint {
@ -112,21 +74,13 @@ export interface ModelEndpoint {
apiKey: string
costPer1kPromptTokens: number
costPer1kCompletionTokens: number
latencyMs: number
latencyMs?: number
isActive: boolean
isDefault: boolean
isDefault?: boolean
createdAt: string
}
export type STTProviderCategory =
| 'groq'
| 'openai'
| 'deepgram'
| 'google'
| 'assemblyai'
| 'azure'
| 'custom'
| 'local-sidecar'
export type STTProviderCategory = 'groq' | 'openai' | 'deepgram' | 'google' | 'assemblyai' | 'azure' | 'custom' | 'local-sidecar'
export interface SttProviderEndpoint {
id: number
@ -166,25 +120,10 @@ export interface CreateSttEndpointDto {
isActive?: boolean
fallbackPriority?: number
extraHeadersJson?: string
memo: string
}
export interface UpdateSttEndpointDto {
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 type UpdateSttEndpointDto = CreateSttEndpointDto
export interface SttTestResult {
success: boolean
@ -200,34 +139,8 @@ export interface SttUsageReport {
totalAudioMinutes: number
totalCost: number
avgLatencyMs: number
providerSummaries: Array<{
provider: string
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
providerSummaries: Array<{ provider: string; modelId: string; totalRequests: number; totalAudioMinutes: number; totalCost: number; avgLatencyMs: number }>
userSummaries: Array<{ userId: number; email: string; totalRequests: number; totalAudioMinutes: number; totalCost: number }>
}
export interface UsageReport {
@ -235,712 +148,92 @@ export interface UsageReport {
totalPromptTokens: number
totalCompletionTokens: number
totalCost: number
timeline: Array<{
date: string
dictations: number
meetingSummaries: number
aiChat: number
ragSearch: number
voiceRealtime: number
totalCost: number
}>
timeline: Array<{ date: string; dictations: number; meetingSummaries: number; aiChat: number; ragSearch: number; voiceRealtime: number; totalCost: number }>
features: FeatureUsageBreakdown[]
userSummaries: Array<{
userId: 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
}>
userSummaries: Array<{ userId: 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) ─────────
const MOCK_NODES: SystemNodeHealth[] = [
{
id: 'whisper-sidecar',
name: 'Faster-Whisper STT Engine',
category: 'stt',
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,
},
async function readJson<T>(path: string): Promise<T> {
const response = await fetchAdminBackend(path)
if (!response.ok) throw new Error(`Admin backend request failed (${response.status})`)
try {
return (await response.json()) as T
} catch {
throw new Error('Admin backend returned invalid JSON')
}
}
const MOCK_USERS: UserItem[] = [
{
id: 1,
uid: 'usr_d3ro_001',
email: 'admin@d3ro.voice',
name: 'D3RO System Architect',
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 },
],
function normalizeRole(value: unknown): UserItem['role'] {
const normalized = typeof value === 'string' ? value.replace(/[_-]/g, '').toLowerCase() : ''
if (normalized === 'superadmin') return 'super_admin'
if (normalized === 'admin') return 'admin'
if (normalized === 'manager') return 'manager'
return 'user'
}
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> {
try {
const res = await fetch(`${API_BASE}/api/admin/stats`, { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
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 ?? [],
const data = await readJson<Partial<ServerStats>>('/stats')
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') {
throw new Error('Admin stats response is incomplete')
}
} catch {
return {
totalUsers: 4580,
activeUsersToday: 1240,
totalRequests: 142890,
totalCost: 24.8912,
serverUptimeSeconds: 864200,
errorCount: 0,
arrUsd: 231480,
mrrUsd: 19290,
tierDistribution: { free: 3420, pro: 842, pro_plus: 318 },
nodes: MOCK_NODES,
pipelines: MOCK_PIPELINES,
featureBreakdown: MOCK_FEATURE_BREAKDOWN,
recentErrors: [],
}
totalUsers: data.totalUsers,
activeUsersToday: data.activeUsersToday,
totalRequests: data.totalRequests,
totalCost: data.totalCost,
serverUptimeSeconds: data.serverUptimeSeconds,
errorCount: data.errorCount,
arrUsd: typeof data.arrUsd === 'number' ? data.arrUsd : null,
mrrUsd: typeof data.mrrUsd === 'number' ? data.mrrUsd : null,
tierDistribution: data.tierDistribution ?? null,
nodes: Array.isArray(data.nodes) ? data.nodes : [],
pipelines: data.pipelines ?? null,
featureBreakdown: Array.isArray(data.featureBreakdown) ? data.featureBreakdown : [],
recentErrors: Array.isArray(data.recentErrors) ? data.recentErrors : []
}
}
export async function fetchUsers(): Promise<UserItem[]> {
try {
const res = await fetch(`${API_BASE}/api/admin/users`, { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
return Array.isArray(data) && data.length > 0 ? data : MOCK_USERS
} catch {
return MOCK_USERS
const data = await readJson<Array<Record<string, unknown>>>('/users')
if (!Array.isArray(data)) throw new Error('Admin users response is invalid')
return data.map((item) => {
if (typeof item.id !== 'number' || typeof item.email !== 'string' || typeof item.createdAt !== 'string') {
throw new Error('Admin users response contains an invalid row')
}
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[]> {
try {
const res = await fetch(`${API_BASE}/api/admin/endpoints`, { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
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 },
],
const data = await readJson<ModelEndpoint[]>('/endpoints')
if (!Array.isArray(data)) throw new Error('Model endpoints response is invalid')
return data
}
export async function fetchSttEndpoints(): Promise<SttProviderEndpoint[]> {
try {
const res = await fetch(`${API_BASE}/api/admin/stt-endpoints`, { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
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,
}
}
const data = await readJson<SttProviderEndpoint[]>('/stt-endpoints')
if (!Array.isArray(data)) throw new Error('STT endpoints response is invalid')
return data
}
export async function fetchSttUsageReport(): Promise<SttUsageReport> {
try {
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
}
return readJson<SttUsageReport>('/stt-usage')
}
export async function fetchUsageReport(): Promise<UsageReport> {
try {
const res = await fetch(`${API_BASE}/api/admin/usage`, { cache: 'no-store' })
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
}
const data = await readJson<Omit<UsageReport, 'timeline' | 'features'> & Partial<Pick<UsageReport, 'timeline' | 'features'>>>('/usage')
return { ...data, timeline: Array.isArray(data.timeline) ? data.timeline : [], features: Array.isArray(data.features) ? data.features : [] }
}

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': {
px: 2,
pb: 1.5,
fontWeight: 600,
fontWeight: 500,
fontSize: '11px',
textTransform: 'uppercase' as const,
letterSpacing: '0.08em',
@ -125,7 +125,7 @@ export const filterBtnSx = (active: boolean) => ({
borderRadius: '999px',
fontFamily: FONT_SANS,
fontSize: '11px',
fontWeight: 600,
fontWeight: 500,
letterSpacing: '0.04em',
textTransform: 'none' as const,
cursor: 'pointer',
@ -166,7 +166,7 @@ export function statusBadgeSx(variant: 'green' | 'red' | 'orange' | 'blue' | 'pu
borderRadius: '999px',
fontSize: '11px',
fontFamily: FONT_SANS,
fontWeight: 600,
fontWeight: 500,
letterSpacing: '0.04em',
bgcolor: c.bg,
color: c.fg,
@ -182,7 +182,7 @@ export const primaryButtonSx = {
color: '#ffffff',
fontFamily: FONT_SANS,
fontSize: '13px',
fontWeight: 600,
fontWeight: 500,
borderRadius: '10px',
px: 2.5,
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
// 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 LOCKOUT_DURATION_MS = 15 * 60 * 1000 // 15 minutes lockout
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.
*/
export function recordFailedAttempt(clientKey: string): { locked: boolean; retryAfterSeconds: number } {
export function recordFailedAttempt(clientKey: string): {
locked: boolean
retryAfterSeconds: number
} {
const now = Date.now()
const record = failedAttemptsMap.get(clientKey)
@ -53,7 +61,7 @@ export function recordFailedAttempt(clientKey: string): { locked: boolean; retry
failedAttemptsMap.set(clientKey, {
count: 1,
firstAttemptAt: now,
lockedUntil: null,
lockedUntil: null
})
return { locked: false, retryAfterSeconds: 0 }
}
@ -79,10 +87,11 @@ export function resetFailedAttempts(clientKey: string): void {
/**
* 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 encodedPayload = Buffer.from(jsonStr).toString('base64url')
const hmac = crypto.createHmac('sha256', JWT_SECRET)
const hmac = createHmac('sha256', sessionSecret)
hmac.update(encodedPayload)
const signature = hmac.digest('base64url')
return `${encodedPayload}.${signature}`
@ -92,31 +101,30 @@ export function signSession(payload: Record<string, unknown>): string {
* Verifies and decodes a cryptographically signed session token.
* 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 {
const sessionSecret = requireAdminSessionSecret()
const parts = tokenString.split('.')
if (parts.length !== 2) return null
const [encodedPayload, providedSignature] = parts
const hmac = crypto.createHmac('sha256', JWT_SECRET)
hmac.update(encodedPayload)
const expectedSignature = hmac.digest('base64url')
if (!/^[A-Za-z0-9_-]+$/.test(providedSignature)) return null
const providedBuf = Buffer.from(providedSignature, 'base64url')
if (providedBuf.length !== 32) return null
const providedBuf = Buffer.from(providedSignature)
const expectedBuf = Buffer.from(expectedSignature)
const hmac = createHmac('sha256', sessionSecret)
hmac.update(encodedPayload)
const expectedBuf = hmac.digest()
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')
const parsed = JSON.parse(jsonStr) as T & { expiresAt?: number }
if (parsed.expiresAt && parsed.expiresAt <= Date.now()) {
return null
}
return parsed
return decodeAdminSessionPayload(encodedPayload)
} catch {
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.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using D3ROVoice.Api.Data;
using D3ROVoice.Api.Dtos;
@ -13,16 +15,19 @@ namespace D3ROVoice.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(Policy = "ManagerOrAbove")]
public class AdminController : ControllerBase
{
private readonly AppDbContext _db;
private readonly ISttProxyService _sttService;
private readonly IAdminOperationService _adminOperations;
private static readonly DateTime _serverStartTime = DateTime.UtcNow;
public AdminController(AppDbContext db, ISttProxyService sttService)
public AdminController(AppDbContext db, ISttProxyService sttService, IAdminOperationService adminOperations)
{
_db = db;
_sttService = sttService;
_adminOperations = adminOperations;
}
[HttpGet("stats")]
@ -75,73 +80,121 @@ public class AdminController : ControllerBase
public async Task<IActionResult> GetEndpoints()
{
var endpoints = await _db.ModelEndpoints
.AsNoTracking()
.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();
return Ok(endpoints);
}
[HttpPost("endpoints")]
[Authorize(Policy = "AdminOrAbove")]
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);
if (existing != null)
return await ExecuteAdminMutationAsync(
"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
{
ModelId = dto.ModelId.Trim(),
ModelName = dto.ModelName.Trim(),
Provider = dto.Provider.Trim(),
EndpointUrl = dto.EndpointUrl.Trim(),
ApiKey = dto.ApiKey ?? "",
ApiKey = dto.ApiKey?.Trim() ?? "",
CostPer1kPromptTokens = dto.CostPer1kPromptTokens,
CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens,
IsActive = true,
CreatedAt = DateTime.UtcNow
};
_db.ModelEndpoints.Add(endpoint);
await _db.SaveChangesAsync();
return Ok(endpoint);
return ToModelEndpointDto(endpoint);
});
}
[HttpPut("endpoints/{id}")]
[Authorize(Policy = "AdminOrAbove")]
public async Task<IActionResult> UpdateEndpoint(int id, [FromBody] UpdateModelEndpointDto dto)
{
var endpoint = await _db.ModelEndpoints.FindAsync(id);
if (endpoint == null) return NotFound();
if (id <= 0 || string.IsNullOrWhiteSpace(dto.ModelName) || string.IsNullOrWhiteSpace(dto.Provider)
|| !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.Provider = dto.Provider.Trim();
endpoint.EndpointUrl = dto.EndpointUrl.Trim();
endpoint.ApiKey = dto.ApiKey ?? "";
if (dto.ApiKey != null) endpoint.ApiKey = dto.ApiKey.Trim();
endpoint.CostPer1kPromptTokens = dto.CostPer1kPromptTokens;
endpoint.CostPer1kCompletionTokens = dto.CostPer1kCompletionTokens;
endpoint.IsActive = dto.IsActive;
await _db.SaveChangesAsync();
return Ok(endpoint);
return ToModelEndpointDto(endpoint);
});
}
[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 (endpoint == null) return NotFound();
if (id <= 0) return BadRequest();
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);
await _db.SaveChangesAsync();
return Ok(new { message = "삭제되었습니다." });
return new { message = "삭제되었습니다.", id };
});
}
// ── STT / Transcription Provider Endpoints ────────────────────────────
@ -163,45 +216,88 @@ public class AdminController : ControllerBase
}
[HttpPost("stt-endpoints")]
[Authorize(Policy = "AdminOrAbove")]
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 Ok(endpoint);
return await ExecuteAdminMutationAsync(
"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}")]
[Authorize(Policy = "AdminOrAbove")]
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);
return Ok(endpoint);
}
catch (KeyNotFoundException)
{
return NotFound(new { message = $"STT Endpoint {id} not found." });
}
try { return await _sttService.UpdateEndpointAsync(id, dto); }
catch (KeyNotFoundException) { throw new AdminOperationException("stt_endpoint_not_found"); }
});
}
[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 (!deleted) return NotFound();
return Ok(new { message = "STT 엔드포인트가 성공적으로 삭제되었습니다." });
if (id <= 0) return BadRequest();
return await ExecuteAdminMutationAsync(
"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")]
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 (!success) return NotFound();
return Ok(new { id, isDefault = true, success = true, message = "기본 클라우드 전사 프로바이더로 설정되었습니다." });
if (id <= 0) return BadRequest();
return await ExecuteAdminMutationAsync(
"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")]
@ -212,9 +308,15 @@ public class AdminController : ControllerBase
}
[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);
}
@ -272,4 +374,113 @@ public class AdminController : ControllerBase
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 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
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);
@ -23,7 +31,8 @@ public record CreateModelEndpointDto(
string EndpointUrl,
string ApiKey,
decimal CostPer1kPromptTokens,
decimal CostPer1kCompletionTokens
decimal CostPer1kCompletionTokens,
string? Memo = null
);
public record UpdateModelEndpointDto(
@ -33,7 +42,8 @@ public record UpdateModelEndpointDto(
string ApiKey,
decimal CostPer1kPromptTokens,
decimal CostPer1kCompletionTokens,
bool IsActive
bool IsActive,
string? Memo = null
);
public record ServerStatsDto(
@ -117,7 +127,8 @@ public record CreateSttEndpointDto(
bool IsDefault,
bool IsActive,
int FallbackPriority,
string? ExtraHeadersJson
string? ExtraHeadersJson,
string? Memo = null
);
public record UpdateSttEndpointDto(
@ -135,9 +146,12 @@ public record UpdateSttEndpointDto(
bool IsDefault,
bool IsActive,
int FallbackPriority,
string? ExtraHeadersJson
string? ExtraHeadersJson,
string? Memo = null
);
public record AdminActionDto(string? Memo);
public record SttTestResultDto(
bool Success,
string Message,
@ -147,6 +161,8 @@ public record SttTestResultDto(
string? ModelId
);
public record DirectSttTestDto(string EndpointUrl, string? ApiKey);
public record SttUsageReportDto(
int TotalTranscriptions,
double TotalAudioMinutes,
@ -172,4 +188,3 @@ public record SttUserUsageSummaryDto(
double TotalAudioMinutes,
decimal TotalCost
);

View file

@ -2,9 +2,11 @@ using System.Text;
using D3ROVoice.Api.Data;
using D3ROVoice.Api.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
var serverStartTime = DateTime.UtcNow;
@ -13,6 +15,19 @@ var serverStartTime = DateTime.UtcNow;
builder.Services.AddControllers();
builder.Services.AddHttpClient();
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 =>
{
@ -45,11 +60,24 @@ builder.Services.AddDbContext<AppDbContext>(options =>
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<ILlmProxyService, LlmProxyService>();
builder.Services.AddScoped<ISttProxyService, SttProxyService>();
builder.Services.AddScoped<IAdminOperationService, AdminOperationService>();
// JWT Authentication Configuration
var secretKey = builder.Configuration["Jwt:SecretKey"]
?? builder.Configuration["JWT_SECRET"]
?? "D3ROVoice_Super_Secure_Secret_Key_2026_Key!";
var secretKey = builder.Configuration["JWT_SECRET"];
if (string.IsNullOrWhiteSpace(secretKey) || Encoding.UTF8.GetByteCount(secretKey) < 32)
{
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);
builder.Services.AddAuthentication(options =>
@ -59,25 +87,103 @@ builder.Services.AddAuthentication(options =>
})
.AddJwtBearer(options =>
{
options.RequireHttpsMetadata = false;
options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
options.SaveToken = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
ValidateIssuer = false,
ValidateAudience = false,
ValidateIssuer = true,
ValidIssuer = jwtIssuer,
ValidateAudience = true,
ValidAudience = jwtAudience,
RequireExpirationTime = true,
ValidateLifetime = true,
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 =>
{
options.AddPolicy("AllowAll", policy =>
options.AddPolicy("ConfiguredOrigins", policy =>
{
policy.AllowAnyOrigin()
policy.WithOrigins(corsOrigins)
.AllowAnyMethod()
.AllowAnyHeader();
.AllowAnyHeader()
.SetPreflightMaxAge(TimeSpan.FromHours(1));
});
});
@ -88,6 +194,75 @@ using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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
if (!db.ModelEndpoints.Any())
@ -205,47 +380,78 @@ using (var scope = app.Services.CreateScope())
db.SaveChanges();
}
// Default Admin User seed & update password to Test1234!
var adminUser = db.Users.FirstOrDefault(u => u.Email == "admin" || u.Email == "admin@d3ro.voice");
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();
}
// Administrative accounts are never seeded with repository credentials.
// Provisioning is performed through the authenticated one-time bootstrap flow.
}
app.UseSwagger();
app.UseSwaggerUI();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors("AllowAll");
app.UseCors("ConfiguredOrigins");
app.Use(async (context, next) =>
{
var isInvitePage =
context.Request.Path.StartsWithSegments("/accept-invite")
|| context.Request.Path.Equals("/accept-invite.html");
if (isInvitePage)
{
context.Response.OnStarting(() =>
{
// The invite token lives in the query string. Do not cache the page and
// prevent CDN HTML transforms (including analytics script injection).
context.Response.Headers["Cache-Control"] = "no-store, no-transform";
context.Response.Headers["Content-Security-Policy"] =
"default-src 'self'; base-uri 'none'; connect-src 'none'; font-src 'self'; " +
"form-action 'none'; frame-ancestors 'none'; img-src 'self' data:; " +
"object-src 'none'; script-src 'self'; style-src 'self'";
context.Response.Headers["Permissions-Policy"] =
"camera=(), microphone=(), geolocation=(), payment=(), usb=()";
context.Response.Headers["Referrer-Policy"] = "no-referrer";
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["X-Frame-Options"] = "DENY";
return Task.CompletedTask;
});
}
if (context.Request.Path.Equals("/accept-invite"))
{
context.Response.StatusCode = StatusCodes.Status308PermanentRedirect;
context.Response.Headers.Location = $"/accept-invite/{context.Request.QueryString}";
return;
}
await next();
});
app.UseDefaultFiles();
// 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.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
@ -272,7 +478,10 @@ app.MapGet("/api/health", () => Results.Ok(new
app.MapControllers();
app.MapFallbackToFile("/accept-invite", "accept-invite.html");
// Fallback to Admin BackOffice UI index.html
app.MapFallbackToFile("/admin/{*path}", "admin/index.html");
app.Run();
public partial class Program { }

View file

@ -2,8 +2,6 @@
# D3RO Voice — Standalone NAS Docker Compose Specification
# Includes: Core API + Promotional Site + Full Next.js Admin CRM
# ============================================================================
version: '3.8'
services:
d3ro-api-server:
image: d3ro-voice-api:latest
@ -15,7 +13,13 @@ services:
- ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_URLS=http://+:5000
- 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}
volumes:
- ${DATA_PATH:-./data}:/app/data
@ -35,6 +39,11 @@ services:
- NODE_ENV=production
- PORT=3001
- 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:
- d3ro-api-server
logging:

View file

@ -1,5 +1,3 @@
version: '3.8'
services:
# ============================================================================
# D3RO Voice — Core Cloud API Server & Official Promotion Landing Site
@ -17,7 +15,13 @@ services:
- ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_URLS=http://+:5000
- 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}
volumes:
- ${DATA_PATH:-./data}:/app/data
@ -43,6 +47,9 @@ services:
- NODE_ENV=production
- PORT=3001
- 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:
- d3ro-api-server
logging: