fase(17): licensing module with RSA validation

This commit is contained in:
debian
2026-03-08 05:20:54 -04:00
parent 1f1678af17
commit 5a28270dc9
45 changed files with 1789 additions and 48 deletions

View File

@@ -1,8 +1,60 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Shield } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Shield, CheckCircle2, XCircle, Loader2 } from 'lucide-react'
import { apiFetch } from '@/lib/api'
interface LicenseStatus {
plan: 'free' | 'pro' | 'enterprise'
organizationName: string
email: string
issuedAt: string
expiresAt: string | null
isValid: boolean
features: string[]
}
const PLAN_LABELS: Record<string, string> = {
free: 'Free / OSS',
pro: 'Pro',
enterprise: 'Enterprise',
}
export function LicenseSection() {
const [licenseKey, setLicenseKey] = useState('')
const [activateError, setActivateError] = useState<string | null>(null)
const queryClient = useQueryClient()
const { data: status, isLoading } = useQuery<LicenseStatus>({
queryKey: ['license-status'],
queryFn: () => apiFetch('/api/license/status'),
})
const { mutate: activate, isPending } = useMutation({
mutationFn: (key: string) =>
apiFetch<{ message: string; license: LicenseStatus }>('/api/license/activate', {
method: 'POST',
body: JSON.stringify({ licenseKey: key }),
}),
onSuccess: () => {
setLicenseKey('')
setActivateError(null)
void queryClient.invalidateQueries({ queryKey: ['license-status'] })
},
onError: (err: Error) => {
setActivateError(err.message)
},
})
const handleActivate = () => {
if (!licenseKey.trim()) return
setActivateError(null)
activate(licenseKey.trim())
}
return (
<div className="space-y-6 max-w-xl">
<div>
@@ -17,14 +69,92 @@ export function LicenseSection() {
<CardTitle className="text-base">Current Plan</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading license status...
</div>
) : status ? (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Plan</span>
<Badge variant={status.plan === 'free' ? 'secondary' : 'default'}>
{PLAN_LABELS[status.plan] ?? status.plan}
</Badge>
</div>
{status.plan !== 'free' && (
<>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Organization</span>
<span className="font-medium">{status.organizationName}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Email</span>
<span className="font-medium">{status.email}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Issued</span>
<span>{new Date(status.issuedAt).toLocaleDateString()}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Expires</span>
<span>{status.expiresAt ? new Date(status.expiresAt).toLocaleDateString() : 'Never'}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Status</span>
{status.isValid ? (
<span className="flex items-center gap-1 text-green-600">
<CheckCircle2 className="h-4 w-4" /> Valid
</span>
) : (
<span className="flex items-center gap-1 text-red-600">
<XCircle className="h-4 w-4" /> Expired
</span>
)}
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Features</span>
<div className="flex flex-wrap gap-1">
{status.features.map((f) => (
<Badge key={f} variant="outline" className="text-xs">
{f}
</Badge>
))}
</div>
</div>
</>
)}
</>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Activate License</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Plan</span>
<Badge>Free / OSS</Badge>
</div>
<CardDescription>
License activation will be available in Phase 17 (RSA-signed keys with feature entitlements).
</CardDescription>
<p className="text-sm text-muted-foreground">
Paste your license key below to unlock Pro or Enterprise features.
</p>
<Textarea
placeholder="Paste license key..."
value={licenseKey}
onChange={(e) => setLicenseKey(e.target.value)}
className="font-mono text-xs h-28 resize-none"
/>
{activateError && (
<p className="text-sm text-red-600">{activateError}</p>
)}
<Button
onClick={handleActivate}
disabled={!licenseKey.trim() || isPending}
className="w-full"
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Activate License
</Button>
</CardContent>
</Card>
</div>