d3ro-voice/apps/desktop/src/renderer/components/LicenseTab.tsx
Yun Chan 6ba25f53b7 fix(desktop): surface configuration and provider failures instead of hiding them
Several desktop paths quietly substituted defaults or partial results: a
config write could fall back to a throwaway in-memory store, speech provider
errors were absorbed into empty transcriptions, and meeting exports built
file names from raw titles.

Writes now fail explicitly when the store is unavailable, provider and model
failures reach the UI as errors, and export names pass through one
sanitizer. Settings, license, ad, and support surfaces use the shared theme
tokens, unused hotkey helpers are gone, and the package gains strict
node/renderer typecheck configs plus red-team e2e scenarios for these flows.
2026-09-16 23:23:58 +09:00

471 lines
17 KiB
TypeScript

import { useState } from 'react'
import {
Box,
Divider,
LinearProgress,
TextField,
} from '@mui/material'
import { CircleCheck, XCircle, KeyRound, Sparkles } from 'lucide-react'
import { Led, PhosphorText, PhysicalButton, MetalCard } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { PREMIUM_MODEL_LIMITS } from '@d3ro/core/constants'
import { useLicenseState } from '../hooks/useLicenseState'
export function LicenseTab(): React.ReactElement {
const { t } = useI18n()
const {
licenseInfo, usage, comparison, cloud,
currentTier, isFree, isPro,
handleUpgrade, handleOpenBilling, openCloudSettings,
} = useLicenseState()
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* ── 현재 플랜 + 계정 상태 ── */}
<MetalCard>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Led
color={currentTier === 'free' ? 'amber' : 'green'}
size={12}
pulse={currentTier !== 'free'}
/>
<Box sx={{ flex: 1 }}>
<PhosphorText variant="label" sx={{ mb: 0.5, display: 'block' }}>
{t('license.currentTier')}
</PhosphorText>
<TierLabel tier={currentTier} t={t} />
</Box>
{cloud.authenticated && cloud.userEmail && (
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size }}>
{cloud.userEmail}
</PhosphorText>
)}
</Box>
</MetalCard>
{/* ── 14일 Reverse-Trial 상태 배너 (활성화 시) ── */}
{licenseInfo?.isTrial && licenseInfo.trialExpiresAt && (
<MetalCard sx={{ bgcolor: 'var(--d3-status-warning)', borderColor: d3roPalette.accent.main }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Sparkles size={18} color={d3roPalette.accent.main} />
<Box sx={{ flex: 1 }}>
<PhosphorText variant="compact" sx={{ color: d3roPalette.accent.main, fontWeight: 500 }}>
14-Day Reverse Trial (Pro+)
</PhosphorText>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
{Math.max(0, Math.ceil((licenseInfo.trialExpiresAt - Date.now()) / (24 * 60 * 60 * 1000)))} 100% .
</PhosphorText>
</Box>
</Box>
</MetalCard>
)}
{/* ── 구독 관리 / 업그레이드 ── */}
<MetalCard>
<SubscriptionSection
t={t}
authenticated={cloud.authenticated}
isFree={isFree}
isPro={isPro}
currentTier={currentTier}
onUpgrade={handleUpgrade}
onManage={handleOpenBilling}
onSignIn={openCloudSettings}
/>
</MetalCard>
{/* ── 오프라인 라이선스 키 등록 ── */}
<MetalCard>
<LicenseKeySection currentTier={currentTier} />
</MetalCard>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{/* ── 일일 사용량 ── */}
<UsageSection t={t} usage={usage} currentTier={currentTier} />
{/* ── 티어 비교표 ── */}
<ComparisonTable t={t} comparison={comparison} />
</Box>
)
}
// ── 하위 컴포넌트 ──────────────────────────────────────
function TierLabel({ tier, t }: { tier: string; t: (k: string) => string }): React.ReactElement {
const color = tier === 'enterprise' || tier === 'team'
? d3roPalette.accent.main
: tier === 'pro_plus'
? d3roPalette.tag.purple
: tier === 'pro'
? d3roPalette.tag.green
: d3roPalette.accent.main
const label = tier === 'enterprise'
? 'Enterprise'
: tier === 'team'
? 'Team'
: tier === 'pro_plus'
? t('license.proPlus')
: tier === 'pro'
? t('license.pro')
: t('license.free')
return <PhosphorText variant="value" sx={{ color }}>{label}</PhosphorText>
}
function LicenseKeySection({ currentTier }: { currentTier: string }): React.ReactElement {
const [licenseKeyInput, setLicenseKeyInput] = useState('')
const [feedback, setFeedback] = useState<{ success: boolean; message: string } | null>(null)
const [loading, setLoading] = useState(false)
const handleActivateKey = async () => {
if (!licenseKeyInput.trim()) return
setLoading(true)
setFeedback(null)
try {
const res = await window.electronAPI.license.activate({ licenseKey: licenseKeyInput.trim() })
if (res.success && res.data.success) {
setFeedback({ success: true, message: res.data.message || '라이센스가 성공적으로 활성화되었습니다.' })
setLicenseKeyInput('')
} else {
setFeedback({ success: false, message: (res.success && res.data.message) ? res.data.message : '유효하지 않은 라이센스 키입니다.' })
}
} catch (err) {
setFeedback({ success: false, message: `활성화 실패: ${err}` })
} finally {
setLoading(false)
}
}
const handleDeactivate = async () => {
setLoading(true)
try {
await window.electronAPI.license.deactivate()
setFeedback({ success: true, message: '라이센스가 비활성화되어 Free 티어로 전환되었습니다.' })
} finally {
setLoading(false)
}
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<KeyRound size={16} color={d3roPalette.text.label} />
<PhosphorText variant="label">
(Ed25519)
</PhosphorText>
</Box>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size }}>
Ed25519 (D3RO-LIC-...) .
</PhosphorText>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
size="small"
fullWidth
placeholder="D3RO-LIC-..."
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
disabled={loading}
sx={{
fontFamily: d3roFontMono,
'& .MuiOutlinedInput-root': {
bgcolor: d3roPalette.bg.inset,
borderRadius: d3roRadius.button,
fontSize: d3roTypo.small.size,
fontFamily: d3roFontMono,
color: d3roPalette.text.primary,
'& fieldset': { borderColor: d3roPalette.border.subtle },
'&:hover fieldset': { borderColor: d3roPalette.accent.main },
},
}}
/>
<PhysicalButton onClick={handleActivateKey} disabled={loading || !licenseKeyInput.trim()} sx={{ px: 2.5, whiteSpace: 'nowrap' }}>
<PhosphorText variant="compact"></PhosphorText>
</PhysicalButton>
{currentTier !== 'free' && (
<PhysicalButton onClick={handleDeactivate} disabled={loading} sx={{ px: 2, whiteSpace: 'nowrap' }}>
<PhosphorText variant="compact" sx={{ color: d3roPalette.tag.red }}></PhosphorText>
</PhysicalButton>
)}
</Box>
{feedback && (
<PhosphorText
variant="small"
sx={{ color: feedback.success ? d3roPalette.tag.green : d3roPalette.tag.red }}
>
{feedback.message}
</PhosphorText>
)}
</Box>
)
}
interface SubscriptionSectionProps {
t: (k: string) => string
authenticated: boolean
isFree: boolean
isPro: boolean
currentTier: string
onUpgrade: (tier: 'pro' | 'pro_plus') => void
onManage: () => void
onSignIn: () => void
}
function SubscriptionSection({ t, authenticated, isFree, isPro, onUpgrade, onManage, onSignIn }: SubscriptionSectionProps): React.ReactElement {
if (!authenticated) {
return (
<Box sx={{ textAlign: 'center', py: 1 }}>
<PhosphorText variant="body" sx={{ mb: 1.5, display: 'block', color: d3roPalette.text.secondary }}>
{t('license.signInRequired')}
</PhosphorText>
<PhysicalButton onClick={onSignIn} sx={{ width: '100%' }}>
{t('license.signInToUpgrade')}
</PhysicalButton>
</Box>
)
}
if (isFree) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<PhosphorText variant="label" sx={{ display: 'block' }}>
{t('license.subscribe')}
</PhosphorText>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size, mb: 0.5, display: 'block' }}>
{t('license.upgradeToProDesc')}
</PhosphorText>
<UpgradeButton tier="pro" t={t} onUpgrade={onUpgrade} />
<UpgradeButton tier="pro_plus" t={t} onUpgrade={onUpgrade} />
</Box>
)
}
if (isPro) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<ActivePlanIndicator label={`${t('license.currentPlanActive')}${t('license.proPlan')}`} color={d3roPalette.tag.green} />
<UpgradeButton tier="pro_plus" t={t} onUpgrade={onUpgrade} />
<PhysicalButton onClick={onManage} sx={{ width: '100%' }}>
<PhosphorText variant="compact">{t('license.subscriptionManage')}</PhosphorText>
</PhysicalButton>
</Box>
)
}
if (currentTier === 'team') {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<ActivePlanIndicator label="Team Workspace Plan Active" color={d3roPalette.accent.main} />
<PhysicalButton onClick={onManage} sx={{ width: '100%' }}>
<PhosphorText variant="compact">{t('license.subscriptionManage')}</PhosphorText>
</PhysicalButton>
</Box>
)
}
if (currentTier === 'enterprise') {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<ActivePlanIndicator label="Enterprise Dedicated Plan Active" color={d3roPalette.accent.main} />
<PhysicalButton onClick={onManage} sx={{ width: '100%' }}>
<PhosphorText variant="compact">{t('license.subscriptionManage')}</PhosphorText>
</PhysicalButton>
</Box>
)
}
// Pro+
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<ActivePlanIndicator label={`${t('license.currentPlanActive')}${t('license.proPlusPlan')}`} color={d3roPalette.tag.purple} />
<PhysicalButton onClick={onManage} sx={{ width: '100%' }}>
<PhosphorText variant="compact">{t('license.subscriptionManage')}</PhosphorText>
</PhysicalButton>
</Box>
)
}
function ActivePlanIndicator({ label, color }: { label: string; color: string }): React.ReactElement {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color="green" size={8} pulse />
<PhosphorText variant="compact" sx={{ color }}>{label}</PhosphorText>
</Box>
)
}
function UpgradeButton({ tier, t, onUpgrade }: { tier: 'pro' | 'pro_plus'; t: (k: string) => string; onUpgrade: (t: 'pro' | 'pro_plus') => void }): React.ReactElement {
const planLabel = tier === 'pro_plus' ? t('license.proPlusPlan') : t('license.proPlan')
return (
<PhysicalButton onClick={() => onUpgrade(tier)} sx={{ width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="green" size={8} />
<PhosphorText variant="compact">
{t('license.upgrade')} {planLabel}
</PhosphorText>
</Box>
</PhysicalButton>
)
}
function UsageSection({ t, usage, currentTier }: { t: (k: string) => string; usage: Array<{ feature: string; used: number; limit: number; remaining: number }>; currentTier: string }): React.ReactElement | null {
if (usage.length === 0) return null
return (
<>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.label }}>
{t('license.dailyUsage')}
</PhosphorText>
{usage.map((q) => (
<QuotaBar key={q.feature} t={t} feature={q.feature} used={q.used} limit={q.limit} remaining={q.remaining} />
))}
{/* Premium 모델별 쿼터 */}
{PREMIUM_MODEL_LIMITS[currentTier as keyof typeof PREMIUM_MODEL_LIMITS]?.length > 0 && (
<>
<PhosphorText variant="label" sx={{ mt: 1, color: d3roPalette.tag.green }}>
{t('license.premiumQuota')}
</PhosphorText>
{PREMIUM_MODEL_LIMITS[currentTier as keyof typeof PREMIUM_MODEL_LIMITS].map((m) => (
<Box key={m.model}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<PhosphorText variant="small">{t(m.i18nKey)}</PhosphorText>
<PhosphorText variant="small" sx={{ color: d3roPalette.accent.main }}>
{m.limit === -1
? t('license.unlimited')
: `${m.limit}/${t(m.period === 'weekly' ? 'license.quotaWeekly' : 'license.quotaDaily')}`}
</PhosphorText>
</Box>
{m.limit > 0 && (
<LinearProgress
variant="determinate"
value={0}
sx={{
height: 4,
borderRadius: d3roRadius.xs,
bgcolor: d3roPalette.bg.inset,
'& .MuiLinearProgress-bar': {
bgcolor: d3roPalette.tag.green,
borderRadius: d3roRadius.xs,
},
}}
/>
)}
</Box>
))}
</>
)}
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
</>
)
}
function QuotaBar({ t, feature, used, limit, remaining }: { t: (k: string, p?: Record<string, string>) => string; feature: string; used: number; limit: number; remaining: number }): React.ReactElement {
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<PhosphorText variant="small">
{t(`license.feature.${feature}`)}
</PhosphorText>
<PhosphorText variant="small" sx={{
color: limit === -1 ? d3roPalette.tag.green : remaining === 0 ? d3roPalette.tag.red : d3roPalette.accent.main,
}}>
{limit === -1
? t('license.unlimited')
: t('license.quotaUsed', { used: String(used), limit: String(limit) })}
</PhosphorText>
</Box>
{limit > 0 && (
<LinearProgress
variant="determinate"
value={Math.min(100, (used / limit) * 100)}
sx={{
height: 4,
borderRadius: d3roRadius.xs,
bgcolor: d3roPalette.bg.inset,
'& .MuiLinearProgress-bar': {
bgcolor: used >= limit ? d3roPalette.tag.red : d3roPalette.accent.main,
borderRadius: d3roRadius.xs,
},
}}
/>
)}
</Box>
)
}
function ComparisonTable({ t, comparison }: { t: (k: string) => string; comparison: Array<{ feature: string; featureLabel: string; free: boolean | string; pro: boolean | string; proPlus: boolean | string }> }): React.ReactElement | null {
if (comparison.length === 0) return null
return (
<>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.label }}>
{t('license.tierComparison')}
</PhosphorText>
<Box
component="table"
sx={{
width: '100%',
borderCollapse: 'collapse',
fontFamily: d3roFontMono,
fontSize: d3roTypo.small.size,
'& th, & td': {
py: 0.5,
px: 1,
textAlign: 'center',
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
},
'& th': {
color: d3roPalette.text.label,
fontWeight: d3roTypo.label.weight,
letterSpacing: d3roTypo.label.spacing,
textTransform: 'uppercase',
},
'& td:first-of-type': {
textAlign: 'left',
color: d3roPalette.text.primary,
},
}}
>
<thead>
<tr>
<th>{''}</th>
<th>{t('license.free')}</th>
<th style={{ color: d3roPalette.tag.green }}>{t('license.pro')}</th>
<th style={{ color: d3roPalette.tag.purple }}>{t('license.proPlus')}</th>
</tr>
</thead>
<tbody>
{comparison.map((row) => (
<tr key={row.feature}>
<td>{t(row.featureLabel)}</td>
<td><TierCell value={row.free} /></td>
<td><TierCell value={row.pro} /></td>
<td><TierCell value={row.proPlus} /></td>
</tr>
))}
</tbody>
</Box>
</>
)
}
function TierCell({ value }: { value: boolean | string }): React.ReactElement {
if (value === true) {
return <CircleCheck size={14} style={{ color: d3roPalette.tag.green }} />
}
if (value === false) {
return <XCircle size={14} style={{ color: d3roPalette.text.disabled }} />
}
return (
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size, color: d3roPalette.accent.main }}>
{value}
</PhosphorText>
)
}