d3ro-voice/apps/admin/src/components/license-issuer-dialog.tsx
Yun Chan 88f24d84a1 refactor(core): keep plan prices, cloud quotas and public URLs in one contract (WS-A)
Prices, quotas and site URLs were copied by hand into the edge functions,
admin, desktop and the landing site, and the copies disagreed (Payple billed
9,900/29,900 KRW, admin labels said 12,900/24,900 KRW and $9.9/$19.9, the
site said 2,900/8,900 KRW).

- packages/core/src/plan-catalog.ts is the single source for PLAN_PRICE_KRW
  (Free 0 / Pro 2,900 / Pro+ 8,900 a month) and PLAN_QUOTA.
- packages/core/src/web-urls.ts is the single source for the public origin,
  the /app web-app base path, SITE_URLS and billingUrl().
- Deno cannot bundle packages/core, so scripts/ci/sync-core-contract.mjs
  generates _shared/core-contract.generated.ts; `npm run contract:check`
  fails on drift (same pattern as version:sync).
- Payple checkout, renewal and webhook amount checks now bill the catalog
  price, so existing subscribers move to the new price at their next renewal.
  quota.ts, team-contract.ts and the tests read the generated values.
- Admin MRR/ARR is computed in KRW from the catalog; license labels, the
  release link and desktop PREMIUM_LLM limits derive from core; the site
  imports prices and quotas directly.

Policy: docs/REFACTOR_POLICY.md Wave 3, W3-1 and W3-2.
2026-09-26 15:48:18 +09:00

256 lines
8.8 KiB
TypeScript

'use client'
// apps/admin/src/components/license-issuer-dialog.tsx
// Ed25519 라이선스 발급 다이얼로그 — 서명은 서버(/api/admin/license)에서만 수행한다.
import { useState } from 'react'
import {
Dialog,
DialogTitle,
DialogContent,
Box,
Typography,
IconButton,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Button,
Alert
} from '@mui/material'
import type { LicenseTier } from '@d3ro/core/types'
import { PLAN_PRICE_KRW } from '@d3ro/core/plan-catalog'
import { d3roPalette, d3roFontMono, d3roRadius } from '@d3ro/ui/theme'
import { C, FONT_SANS, FONT_MONO, primaryButtonSx } from '@/lib/console-theme'
const krw = new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW', maximumFractionDigits: 0 })
interface LicenseIssuerDialogProps {
open: boolean
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')
const [validity, setValidity] = useState<'30d' | '365d' | 'lifetime'>('365d')
const [machineId, setMachineId] = useState('')
const [teamId, setTeamId] = useState('')
const [generatedKey, setGeneratedKey] = useState<string | null>(null)
const [auditRecorded, setAuditRecorded] = useState(true)
const [loading, setLoading] = useState(false)
const [copied, setCopied] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleGenerate = async (): Promise<void> => {
setError(null)
setCopied(false)
if (!customerEmail.trim()) {
setError('Customer Email is required')
return
}
setLoading(true)
try {
const response = await fetch('/api/admin/license', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customerEmail: customerEmail.trim(),
tier,
validity,
machineId: machineId.trim(),
teamId: teamId.trim()
})
})
const data = (await response.json()) as Record<string, unknown>
if (!response.ok || data.success !== true || typeof data.licenseKey !== 'string') {
throw new Error(describeIssueError(data.error))
}
setGeneratedKey(data.licenseKey)
setAuditRecorded(data.auditRecorded !== false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate license key')
} finally {
setLoading(false)
}
}
const handleCopy = (): void => {
if (!generatedKey) return
void navigator.clipboard.writeText(generatedKey)
setCopied(true)
setTimeout(() => setCopied(false), 3000)
}
const inputSx = {
'& .MuiInputBase-root': {
fontFamily: d3roFontMono,
fontSize: 13,
color: d3roPalette.text.primary,
bgcolor: d3roPalette.bg.inset,
borderRadius: d3roRadius.button
},
'& .MuiInputLabel-root': {
fontFamily: FONT_MONO,
fontSize: 12,
color: C.dim
}
}
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
bgcolor: C.app,
border: `1px solid ${C.borderHl}`,
borderRadius: '16px',
boxShadow: '0 20px 40px var(--d3-scrim)',
p: 1
}
}}
>
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', pb: 1 }}>
<Box>
<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 SERVER-SIGNED OFFLINE / ENTERPRISE TOKEN
</Typography>
</Box>
<IconButton onClick={onClose} size="small" sx={{ color: C.dim }}>
✕
</IconButton>
</DialogTitle>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
<TextField
fullWidth
label="Customer Email *"
placeholder="enterprise-client@company.com"
value={customerEmail}
onChange={(e) => setCustomerEmail(e.target.value)}
sx={inputSx}
/>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<FormControl fullWidth sx={inputSx}>
<InputLabel>Plan / Tier</InputLabel>
<Select value={tier} onChange={(e) => setTier(e.target.value as LicenseTier)} label="Plan / Tier">
<MenuItem value="pro">Pro ({krw.format(PLAN_PRICE_KRW.pro)} / month)</MenuItem>
<MenuItem value="pro_plus">Pro+ ({krw.format(PLAN_PRICE_KRW.pro_plus)} / month)</MenuItem>
<MenuItem value="team">Team</MenuItem>
<MenuItem value="enterprise">Enterprise (Custom)</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth sx={inputSx}>
<InputLabel>Validity Period</InputLabel>
<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>
</Select>
</FormControl>
</Box>
{(tier === 'team' || tier === 'enterprise') && (
<TextField
fullWidth
label="Team / Org Name (Optional)"
placeholder="Engineering AI Squad"
value={teamId}
onChange={(e) => setTeamId(e.target.value)}
sx={inputSx}
/>
)}
<TextField
fullWidth
label="Target Machine ID (Optional Hardware Lock)"
placeholder="e.g. 94dbaa34... (Leave blank for any device)"
value={machineId}
onChange={(e) => setMachineId(e.target.value)}
sx={inputSx}
/>
{error && <Alert severity="error" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>{error}</Alert>}
<Button
variant="contained"
onClick={() => void handleGenerate()}
disabled={loading}
sx={{
...primaryButtonSx,
py: 1.2,
background: `linear-gradient(135deg, ${C.purple} 0%, ${C.accent} 100%)`,
fontWeight: 600
}}
>
{loading ? 'Signing…' : '⚡ Generate Ed25519 Signed License'}
</Button>
{generatedKey && (
<Box sx={{ mt: 1, p: 2, bgcolor: 'var(--d3-scrim)', borderRadius: '10px', border: `1px solid ${C.borderStrong}` }}>
{!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
sx={{
fontFamily: FONT_MONO,
fontSize: '11px',
color: C.bright,
p: 1.5,
bgcolor: 'var(--d3-overlay-strong)',
borderRadius: '6px',
wordBreak: 'break-all',
userSelect: 'all',
mb: 1.5
}}
>
{generatedKey}
</Typography>
<Button
variant="outlined"
fullWidth
onClick={handleCopy}
sx={{
fontFamily: FONT_MONO,
fontSize: '12px',
borderColor: copied ? C.green : C.purple400,
color: copied ? C.green400 : C.bright
}}
>
{copied ? '✓ Copied to Clipboard!' : '📋 Copy License Key'}
</Button>
</Box>
)}
</DialogContent>
</Dialog>
)
}