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.
256 lines
8.8 KiB
TypeScript
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>
|
|
)
|
|
}
|