feat: Complete licensing module with admin and customer UIs

Admin SPA:
- Licenses list page with search, filter, pagination
- License detail page with activation history
- Copy license key, view details, revoke functionality

Customer SPA:
- My Account > Licenses page
- View licenses with activation info
- Copy license key
- Deactivate devices

Backend integration:
- Routes registered in App.tsx and Account/index.tsx
- License nav item in account sidebar (conditional on module enabled)
This commit is contained in:
Dwindi Ramadhana
2026-01-05 16:29:37 +07:00
parent b367c1fcf8
commit 3d2bab90ec
6 changed files with 798 additions and 5 deletions

View File

@@ -0,0 +1,233 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table';
import { ArrowLeft, Key, Monitor, Globe, Clock } from 'lucide-react';
import { __ } from '@/lib/i18n';
interface Activation {
id: number;
license_id: number;
domain: string | null;
ip_address: string | null;
machine_id: string | null;
user_agent: string | null;
status: 'active' | 'deactivated';
activated_at: string;
deactivated_at: string | null;
}
interface LicenseDetail {
id: number;
license_key: string;
product_id: number;
product_name: string;
order_id: number;
user_id: number;
user_email: string;
user_name: string;
status: 'active' | 'revoked' | 'expired';
activation_limit: number;
activation_count: number;
activations_remaining: number;
expires_at: string | null;
is_expired: boolean;
created_at: string;
updated_at: string;
activations: Activation[];
}
export default function LicenseDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: license, isLoading } = useQuery<LicenseDetail>({
queryKey: ['license', id],
queryFn: () => api.get(`/licenses/${id}`),
enabled: !!id,
});
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
);
}
if (!license) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">{__('License not found')}</p>
<Button variant="link" onClick={() => navigate('/products/licenses')}>
{__('Back to Licenses')}
</Button>
</div>
);
}
const getStatusBadge = () => {
if (license.status === 'revoked') {
return <Badge variant="destructive">{__('Revoked')}</Badge>;
}
if (license.is_expired) {
return <Badge variant="secondary">{__('Expired')}</Badge>;
}
return <Badge variant="default">{__('Active')}</Badge>;
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate('/products/licenses')}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Key className="h-6 w-6" />
{__('License Details')}
</h1>
<p className="text-muted-foreground font-mono text-sm">{license.license_key}</p>
</div>
<div className="ml-auto">{getStatusBadge()}</div>
</div>
{/* Info Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">{__('Product')}</CardTitle>
</CardHeader>
<CardContent>
<p className="font-semibold">{license.product_name}</p>
<p className="text-xs text-muted-foreground">Order #{license.order_id}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">{__('Customer')}</CardTitle>
</CardHeader>
<CardContent>
<p className="font-semibold">{license.user_name}</p>
<p className="text-xs text-muted-foreground">{license.user_email}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">{__('Activations')}</CardTitle>
</CardHeader>
<CardContent>
<p className="font-semibold">
{license.activation_count} / {license.activation_limit === 0 ? '∞' : license.activation_limit}
</p>
<p className="text-xs text-muted-foreground">
{license.activations_remaining === -1 ? __('Unlimited') : `${license.activations_remaining} ${__('remaining')}`}
</p>
</CardContent>
</Card>
</div>
{/* Dates */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">{__('Dates')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-muted-foreground">{__('Created')}</p>
<p>{new Date(license.created_at).toLocaleString()}</p>
</div>
<div>
<p className="text-muted-foreground">{__('Expires')}</p>
<p className={license.is_expired ? 'text-red-500' : ''}>
{license.expires_at ? new Date(license.expires_at).toLocaleDateString() : __('Never')}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Activations */}
<Card>
<CardHeader>
<CardTitle>{__('Activation History')}</CardTitle>
<CardDescription>
{__('All activations and deactivations for this license')}
</CardDescription>
</CardHeader>
<CardContent>
{license.activations.length === 0 ? (
<p className="text-center py-8 text-muted-foreground">
{__('No activations yet')}
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{__('Domain/Machine')}</TableHead>
<TableHead>{__('IP Address')}</TableHead>
<TableHead>{__('Activated')}</TableHead>
<TableHead>{__('Status')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{license.activations.map((activation) => (
<TableRow key={activation.id}>
<TableCell>
<div className="flex items-center gap-2">
{activation.domain ? (
<>
<Globe className="h-4 w-4 text-muted-foreground" />
<span>{activation.domain}</span>
</>
) : activation.machine_id ? (
<>
<Monitor className="h-4 w-4 text-muted-foreground" />
<span className="font-mono text-xs">{activation.machine_id}</span>
</>
) : (
<span className="text-muted-foreground">{__('Unknown')}</span>
)}
</div>
</TableCell>
<TableCell>
<span className="font-mono text-xs">{activation.ip_address || '-'}</span>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Clock className="h-3 w-3 text-muted-foreground" />
{new Date(activation.activated_at).toLocaleString()}
</div>
</TableCell>
<TableCell>
{activation.status === 'active' ? (
<Badge variant="default">{__('Active')}</Badge>
) : (
<Badge variant="secondary">{__('Deactivated')}</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,292 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Search, Key, Ban, Eye, Copy, Check } from 'lucide-react';
import { toast } from 'sonner';
import { __ } from '@/lib/i18n';
import { useNavigate } from 'react-router-dom';
interface License {
id: number;
license_key: string;
product_id: number;
product_name: string;
order_id: number;
user_id: number;
user_email: string;
user_name: string;
status: 'active' | 'revoked' | 'expired';
activation_limit: number;
activation_count: number;
activations_remaining: number;
expires_at: string | null;
is_expired: boolean;
created_at: string;
}
interface LicensesResponse {
licenses: License[];
total: number;
page: number;
per_page: number;
}
export default function Licenses() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [search, setSearch] = useState('');
const [status, setStatus] = useState<string>('');
const [page, setPage] = useState(1);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const { data, isLoading } = useQuery<LicensesResponse>({
queryKey: ['licenses', { search, status, page }],
queryFn: () => api.get('/licenses', {
params: { search, status: status || undefined, page, per_page: 20 }
}),
});
const revokeMutation = useMutation({
mutationFn: (id: number) => api.del(`/licenses/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['licenses'] });
toast.success(__('License revoked'));
},
onError: () => {
toast.error(__('Failed to revoke license'));
},
});
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 2000);
toast.success(__('License key copied'));
};
const getStatusBadge = (license: License) => {
if (license.status === 'revoked') {
return <Badge variant="destructive">{__('Revoked')}</Badge>;
}
if (license.is_expired) {
return <Badge variant="secondary">{__('Expired')}</Badge>;
}
return <Badge variant="default">{__('Active')}</Badge>;
};
const totalPages = data ? Math.ceil(data.total / data.per_page) : 1;
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Key className="h-6 w-6" />
{__('Licenses')}
</h1>
<p className="text-muted-foreground">
{__('Manage software licenses for your digital products')}
</p>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={__('Search license keys...')}
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="!pl-9"
/>
</div>
<Select value={status} onValueChange={(v) => { setStatus(v); setPage(1); }}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={__('All Statuses')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{__('All Statuses')}</SelectItem>
<SelectItem value="active">{__('Active')}</SelectItem>
<SelectItem value="revoked">{__('Revoked')}</SelectItem>
</SelectContent>
</Select>
</div>
{/* Table */}
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>{__('License Key')}</TableHead>
<TableHead>{__('Product')}</TableHead>
<TableHead>{__('Customer')}</TableHead>
<TableHead>{__('Activations')}</TableHead>
<TableHead>{__('Status')}</TableHead>
<TableHead>{__('Expires')}</TableHead>
<TableHead className="w-[100px]">{__('Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
{__('Loading...')}
</TableCell>
</TableRow>
) : data?.licenses.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
{__('No licenses found')}
</TableCell>
</TableRow>
) : (
data?.licenses.map((license) => (
<TableRow key={license.id}>
<TableCell>
<div className="flex items-center gap-2">
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">
{license.license_key}
</code>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => copyToClipboard(license.license_key)}
>
{copiedKey === license.license_key ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</TableCell>
<TableCell>
<span className="font-medium">{license.product_name}</span>
</TableCell>
<TableCell>
<div>
<p className="font-medium">{license.user_name}</p>
<p className="text-xs text-muted-foreground">{license.user_email}</p>
</div>
</TableCell>
<TableCell>
<span className={license.activations_remaining === 0 ? 'text-red-500' : ''}>
{license.activation_count} / {license.activation_limit === 0 ? '∞' : license.activation_limit}
</span>
</TableCell>
<TableCell>{getStatusBadge(license)}</TableCell>
<TableCell>
{license.expires_at ? (
<span className={license.is_expired ? 'text-red-500' : ''}>
{new Date(license.expires_at).toLocaleDateString()}
</span>
) : (
<span className="text-muted-foreground">{__('Never')}</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/products/licenses/${license.id}`)}
>
<Eye className="h-4 w-4" />
</Button>
{license.status === 'active' && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon">
<Ban className="h-4 w-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Revoke License')}</AlertDialogTitle>
<AlertDialogDescription>
{__('This will permanently revoke the license. The customer will no longer be able to use it.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => revokeMutation.mutate(license.id)}
className="bg-destructive text-destructive-foreground"
>
{__('Revoke')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{__('Showing')} {((page - 1) * 20) + 1} - {Math.min(page * 20, data?.total || 0)} {__('of')} {data?.total || 0}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
>
{__('Previous')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
{__('Next')}
</Button>
</div>
</div>
)}
</div>
);
}