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:
258
customer-spa/src/pages/Account/Licenses.tsx
Normal file
258
customer-spa/src/pages/Account/Licenses.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user