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,258 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Key, Copy, Check, ChevronDown, ChevronUp, Monitor, Globe, Power } from 'lucide-react';
import { toast } from 'sonner';
interface Activation {
id: number;
domain: string | null;
machine_id: string | null;
ip_address: string | null;
status: 'active' | 'deactivated';
activated_at: string;
}
interface License {
id: number;
license_key: string;
product_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;
activations: Activation[];
}
export default function Licenses() {
const queryClient = useQueryClient();
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const [expandedLicense, setExpandedLicense] = useState<number | null>(null);
const { data: licenses = [], isLoading } = useQuery<License[]>({
queryKey: ['account-licenses'],
queryFn: () => api.get('/account/licenses'),
});
const deactivateMutation = useMutation({
mutationFn: ({ licenseId, activationId }: { licenseId: number; activationId: number }) =>
api.post(`/account/licenses/${licenseId}/deactivate`, { activation_id: activationId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['account-licenses'] });
toast.success('Activation deactivated successfully');
},
onError: () => {
toast.error('Failed to deactivate');
},
});
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 2000);
toast.success('License key copied to clipboard');
};
const getStatusStyle = (license: License) => {
if (license.status === 'revoked') {
return 'bg-red-100 text-red-800';
}
if (license.is_expired) {
return 'bg-gray-100 text-gray-800';
}
return 'bg-green-100 text-green-800';
};
const getStatusLabel = (license: License) => {
if (license.status === 'revoked') return 'Revoked';
if (license.is_expired) return 'Expired';
return 'Active';
};
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-blue-600"></div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Key className="h-6 w-6" />
My Licenses
</h1>
<p className="text-gray-500">
Manage your software licenses and activations
</p>
</div>
{licenses.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<Key className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p className="text-gray-500">You don't have any licenses yet.</p>
<p className="text-sm text-gray-400 mt-1">
Purchase a product with licensing to get started.
</p>
</div>
) : (
<div className="space-y-4">
{licenses.map((license) => (
<div key={license.id} className="bg-white rounded-lg border overflow-hidden">
{/* Header */}
<div className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h3 className="text-lg font-semibold">{license.product_name}</h3>
<div className="flex items-center gap-2">
<code className="text-xs bg-gray-100 px-2 py-1 rounded font-mono">
{license.license_key}
</code>
<button
onClick={() => copyToClipboard(license.license_key)}
className="p-1 hover:bg-gray-100 rounded"
>
{copiedKey === license.license_key ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4 text-gray-400" />
)}
</button>
</div>
</div>
<div className="flex items-center gap-2">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusStyle(license)}`}>
{getStatusLabel(license)}
</span>
<button
onClick={() => setExpandedLicense(expandedLicense === license.id ? null : license.id)}
className="p-1 hover:bg-gray-100 rounded"
>
{expandedLicense === license.id ? (
<ChevronUp className="h-5 w-5 text-gray-400" />
) : (
<ChevronDown className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
</div>
{/* Summary Info */}
<div className="grid grid-cols-3 gap-4 mt-4 text-sm">
<div>
<p className="text-gray-500">Activations</p>
<p className="font-medium">
{license.activation_count} / {license.activation_limit === 0 ? '' : license.activation_limit}
</p>
</div>
<div>
<p className="text-gray-500">Purchased</p>
<p className="font-medium">
{new Date(license.created_at).toLocaleDateString()}
</p>
</div>
<div>
<p className="text-gray-500">Expires</p>
<p className={`font-medium ${license.is_expired ? 'text-red-500' : ''}`}>
{license.expires_at
? new Date(license.expires_at).toLocaleDateString()
: 'Never'}
</p>
</div>
</div>
</div>
{/* Expanded Content - Activations */}
{expandedLicense === license.id && (
<div className="border-t p-4 bg-gray-50">
<h4 className="font-medium mb-3">Active Devices</h4>
{license.activations.filter(a => a.status === 'active').length === 0 ? (
<p className="text-sm text-gray-500 py-4 text-center">
No active devices. Activate your license on a device to see it here.
</p>
) : (
<div className="space-y-2">
{license.activations
.filter(a => a.status === 'active')
.map((activation) => (
<div
key={activation.id}
className="flex items-center justify-between bg-white p-3 rounded border"
>
<div className="flex items-center gap-2">
{activation.domain ? (
<>
<Globe className="h-4 w-4 text-gray-400" />
<span className="text-sm">{activation.domain}</span>
</>
) : activation.machine_id ? (
<>
<Monitor className="h-4 w-4 text-gray-400" />
<span className="text-sm font-mono">
{activation.machine_id.substring(0, 16)}...
</span>
</>
) : (
<span className="text-gray-400 text-sm">Unknown device</span>
)}
<span className="text-gray-400 text-xs">
{new Date(activation.activated_at).toLocaleDateString()}
</span>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-600">
<Power className="h-4 w-4 mr-1" />
Deactivate
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Deactivate Device</AlertDialogTitle>
<AlertDialogDescription>
This will deactivate the license on this device. You can reactivate it later if you have available activation slots.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deactivateMutation.mutate({
licenseId: license.id,
activationId: activation.id,
})}
>
Deactivate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import React, { ReactNode, useState, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut } from 'lucide-react';
import { LayoutDashboard, ShoppingBag, Download, MapPin, Heart, User, LogOut, Key } from 'lucide-react';
import { useModules } from '@/hooks/useModules';
import { api } from '@/lib/api/client';
import {
@@ -53,13 +53,16 @@ export function AccountLayout({ children }: AccountLayoutProps) {
{ id: 'downloads', label: 'Downloads', path: '/my-account/downloads', icon: Download },
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
{ id: 'wishlist', label: 'Wishlist', path: '/my-account/wishlist', icon: Heart },
{ id: 'licenses', label: 'Licenses', path: '/my-account/licenses', icon: Key },
{ id: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
];
// Filter out wishlist if module disabled or settings disabled
const menuItems = allMenuItems.filter(item =>
item.id !== 'wishlist' || (isEnabled('wishlist') && wishlistEnabled)
);
// Filter out wishlist if module disabled or settings disabled, licenses if licensing disabled
const menuItems = allMenuItems.filter(item => {
if (item.id === 'wishlist') return isEnabled('wishlist') && wishlistEnabled;
if (item.id === 'licenses') return isEnabled('licensing');
return true;
});
const handleLogout = async () => {
setIsLoggingOut(true);

View File

@@ -9,6 +9,7 @@ import Downloads from './Downloads';
import Addresses from './Addresses';
import Wishlist from './Wishlist';
import AccountDetails from './AccountDetails';
import Licenses from './Licenses';
export default function Account() {
const user = (window as any).woonoowCustomer?.user;
@@ -30,6 +31,7 @@ export default function Account() {
<Route path="downloads" element={<Downloads />} />
<Route path="addresses" element={<Addresses />} />
<Route path="wishlist" element={<Wishlist />} />
<Route path="licenses" element={<Licenses />} />
<Route path="account-details" element={<AccountDetails />} />
<Route path="*" element={<Navigate to="/my-account" replace />} />
</Routes>
@@ -37,3 +39,4 @@ export default function Account() {
</Container>
);
}