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:
@@ -19,6 +19,8 @@ import ProductEdit from '@/routes/Products/Edit';
|
|||||||
import ProductCategories from '@/routes/Products/Categories';
|
import ProductCategories from '@/routes/Products/Categories';
|
||||||
import ProductTags from '@/routes/Products/Tags';
|
import ProductTags from '@/routes/Products/Tags';
|
||||||
import ProductAttributes from '@/routes/Products/Attributes';
|
import ProductAttributes from '@/routes/Products/Attributes';
|
||||||
|
import Licenses from '@/routes/Products/Licenses';
|
||||||
|
import LicenseDetail from '@/routes/Products/Licenses/Detail';
|
||||||
import CouponsIndex from '@/routes/Marketing/Coupons';
|
import CouponsIndex from '@/routes/Marketing/Coupons';
|
||||||
import CouponNew from '@/routes/Marketing/Coupons/New';
|
import CouponNew from '@/routes/Marketing/Coupons/New';
|
||||||
import CouponEdit from '@/routes/Marketing/Coupons/Edit';
|
import CouponEdit from '@/routes/Marketing/Coupons/Edit';
|
||||||
@@ -541,6 +543,8 @@ function AppRoutes() {
|
|||||||
<Route path="/products/categories" element={<ProductCategories />} />
|
<Route path="/products/categories" element={<ProductCategories />} />
|
||||||
<Route path="/products/tags" element={<ProductTags />} />
|
<Route path="/products/tags" element={<ProductTags />} />
|
||||||
<Route path="/products/attributes" element={<ProductAttributes />} />
|
<Route path="/products/attributes" element={<ProductAttributes />} />
|
||||||
|
<Route path="/products/licenses" element={<Licenses />} />
|
||||||
|
<Route path="/products/licenses/:id" element={<LicenseDetail />} />
|
||||||
|
|
||||||
{/* Orders */}
|
{/* Orders */}
|
||||||
<Route path="/orders" element={<OrdersIndex />} />
|
<Route path="/orders" element={<OrdersIndex />} />
|
||||||
|
|||||||
233
admin-spa/src/routes/Products/Licenses/Detail.tsx
Normal file
233
admin-spa/src/routes/Products/Licenses/Detail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
292
admin-spa/src/routes/Products/Licenses/index.tsx
Normal file
292
admin-spa/src/routes/Products/Licenses/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 React, { ReactNode, useState, useEffect } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
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 { useModules } from '@/hooks/useModules';
|
||||||
import { api } from '@/lib/api/client';
|
import { api } from '@/lib/api/client';
|
||||||
import {
|
import {
|
||||||
@@ -53,13 +53,16 @@ export function AccountLayout({ children }: AccountLayoutProps) {
|
|||||||
{ id: 'downloads', label: 'Downloads', path: '/my-account/downloads', icon: Download },
|
{ id: 'downloads', label: 'Downloads', path: '/my-account/downloads', icon: Download },
|
||||||
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
|
{ id: 'addresses', label: 'Addresses', path: '/my-account/addresses', icon: MapPin },
|
||||||
{ id: 'wishlist', label: 'Wishlist', path: '/my-account/wishlist', icon: Heart },
|
{ 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 },
|
{ id: 'account-details', label: 'Account Details', path: '/my-account/account-details', icon: User },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Filter out wishlist if module disabled or settings disabled
|
// Filter out wishlist if module disabled or settings disabled, licenses if licensing disabled
|
||||||
const menuItems = allMenuItems.filter(item =>
|
const menuItems = allMenuItems.filter(item => {
|
||||||
item.id !== 'wishlist' || (isEnabled('wishlist') && wishlistEnabled)
|
if (item.id === 'wishlist') return isEnabled('wishlist') && wishlistEnabled;
|
||||||
);
|
if (item.id === 'licenses') return isEnabled('licensing');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setIsLoggingOut(true);
|
setIsLoggingOut(true);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Downloads from './Downloads';
|
|||||||
import Addresses from './Addresses';
|
import Addresses from './Addresses';
|
||||||
import Wishlist from './Wishlist';
|
import Wishlist from './Wishlist';
|
||||||
import AccountDetails from './AccountDetails';
|
import AccountDetails from './AccountDetails';
|
||||||
|
import Licenses from './Licenses';
|
||||||
|
|
||||||
export default function Account() {
|
export default function Account() {
|
||||||
const user = (window as any).woonoowCustomer?.user;
|
const user = (window as any).woonoowCustomer?.user;
|
||||||
@@ -30,6 +31,7 @@ export default function Account() {
|
|||||||
<Route path="downloads" element={<Downloads />} />
|
<Route path="downloads" element={<Downloads />} />
|
||||||
<Route path="addresses" element={<Addresses />} />
|
<Route path="addresses" element={<Addresses />} />
|
||||||
<Route path="wishlist" element={<Wishlist />} />
|
<Route path="wishlist" element={<Wishlist />} />
|
||||||
|
<Route path="licenses" element={<Licenses />} />
|
||||||
<Route path="account-details" element={<AccountDetails />} />
|
<Route path="account-details" element={<AccountDetails />} />
|
||||||
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
<Route path="*" element={<Navigate to="/my-account" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -37,3 +39,4 @@ export default function Account() {
|
|||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user