From 3d2bab90ecb12b50cbb66aa2c09dde5b42816c9a Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Mon, 5 Jan 2026 16:29:37 +0700 Subject: [PATCH] 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) --- admin-spa/src/App.tsx | 4 + .../src/routes/Products/Licenses/Detail.tsx | 233 ++++++++++++++ .../src/routes/Products/Licenses/index.tsx | 292 ++++++++++++++++++ customer-spa/src/pages/Account/Licenses.tsx | 258 ++++++++++++++++ .../Account/components/AccountLayout.tsx | 13 +- customer-spa/src/pages/Account/index.tsx | 3 + 6 files changed, 798 insertions(+), 5 deletions(-) create mode 100644 admin-spa/src/routes/Products/Licenses/Detail.tsx create mode 100644 admin-spa/src/routes/Products/Licenses/index.tsx create mode 100644 customer-spa/src/pages/Account/Licenses.tsx diff --git a/admin-spa/src/App.tsx b/admin-spa/src/App.tsx index 55aea78..9fee446 100644 --- a/admin-spa/src/App.tsx +++ b/admin-spa/src/App.tsx @@ -19,6 +19,8 @@ import ProductEdit from '@/routes/Products/Edit'; import ProductCategories from '@/routes/Products/Categories'; import ProductTags from '@/routes/Products/Tags'; 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 CouponNew from '@/routes/Marketing/Coupons/New'; import CouponEdit from '@/routes/Marketing/Coupons/Edit'; @@ -541,6 +543,8 @@ function AppRoutes() { } /> } /> } /> + } /> + } /> {/* Orders */} } /> diff --git a/admin-spa/src/routes/Products/Licenses/Detail.tsx b/admin-spa/src/routes/Products/Licenses/Detail.tsx new file mode 100644 index 0000000..79c746d --- /dev/null +++ b/admin-spa/src/routes/Products/Licenses/Detail.tsx @@ -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({ + queryKey: ['license', id], + queryFn: () => api.get(`/licenses/${id}`), + enabled: !!id, + }); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!license) { + return ( +
+

{__('License not found')}

+ +
+ ); + } + + const getStatusBadge = () => { + if (license.status === 'revoked') { + return {__('Revoked')}; + } + if (license.is_expired) { + return {__('Expired')}; + } + return {__('Active')}; + }; + + return ( +
+ {/* Header */} +
+ +
+

+ + {__('License Details')} +

+

{license.license_key}

+
+
{getStatusBadge()}
+
+ + {/* Info Cards */} +
+ + + {__('Product')} + + +

{license.product_name}

+

Order #{license.order_id}

+
+
+ + + + {__('Customer')} + + +

{license.user_name}

+

{license.user_email}

+
+
+ + + + {__('Activations')} + + +

+ {license.activation_count} / {license.activation_limit === 0 ? '∞' : license.activation_limit} +

+

+ {license.activations_remaining === -1 ? __('Unlimited') : `${license.activations_remaining} ${__('remaining')}`} +

+
+
+
+ + {/* Dates */} + + + {__('Dates')} + + +
+
+

{__('Created')}

+

{new Date(license.created_at).toLocaleString()}

+
+
+

{__('Expires')}

+

+ {license.expires_at ? new Date(license.expires_at).toLocaleDateString() : __('Never')} +

+
+
+
+
+ + {/* Activations */} + + + {__('Activation History')} + + {__('All activations and deactivations for this license')} + + + + {license.activations.length === 0 ? ( +

+ {__('No activations yet')} +

+ ) : ( + + + + {__('Domain/Machine')} + {__('IP Address')} + {__('Activated')} + {__('Status')} + + + + {license.activations.map((activation) => ( + + +
+ {activation.domain ? ( + <> + + {activation.domain} + + ) : activation.machine_id ? ( + <> + + {activation.machine_id} + + ) : ( + {__('Unknown')} + )} +
+
+ + {activation.ip_address || '-'} + + +
+ + {new Date(activation.activated_at).toLocaleString()} +
+
+ + {activation.status === 'active' ? ( + {__('Active')} + ) : ( + {__('Deactivated')} + )} + +
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/admin-spa/src/routes/Products/Licenses/index.tsx b/admin-spa/src/routes/Products/Licenses/index.tsx new file mode 100644 index 0000000..6207c5c --- /dev/null +++ b/admin-spa/src/routes/Products/Licenses/index.tsx @@ -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(''); + const [page, setPage] = useState(1); + const [copiedKey, setCopiedKey] = useState(null); + + const { data, isLoading } = useQuery({ + 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 {__('Revoked')}; + } + if (license.is_expired) { + return {__('Expired')}; + } + return {__('Active')}; + }; + + const totalPages = data ? Math.ceil(data.total / data.per_page) : 1; + + return ( +
+ {/* Header */} +
+

+ + {__('Licenses')} +

+

+ {__('Manage software licenses for your digital products')} +

+
+ + {/* Filters */} +
+
+ + { + setSearch(e.target.value); + setPage(1); + }} + className="!pl-9" + /> +
+ +
+ + {/* Table */} +
+ + + + {__('License Key')} + {__('Product')} + {__('Customer')} + {__('Activations')} + {__('Status')} + {__('Expires')} + {__('Actions')} + + + + {isLoading ? ( + + + {__('Loading...')} + + + ) : data?.licenses.length === 0 ? ( + + + {__('No licenses found')} + + + ) : ( + data?.licenses.map((license) => ( + + +
+ + {license.license_key} + + +
+
+ + {license.product_name} + + +
+

{license.user_name}

+

{license.user_email}

+
+
+ + + {license.activation_count} / {license.activation_limit === 0 ? '∞' : license.activation_limit} + + + {getStatusBadge(license)} + + {license.expires_at ? ( + + {new Date(license.expires_at).toLocaleDateString()} + + ) : ( + {__('Never')} + )} + + +
+ + {license.status === 'active' && ( + + + + + + + {__('Revoke License')} + + {__('This will permanently revoke the license. The customer will no longer be able to use it.')} + + + + {__('Cancel')} + revokeMutation.mutate(license.id)} + className="bg-destructive text-destructive-foreground" + > + {__('Revoke')} + + + + + )} +
+
+
+ )) + )} +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ {__('Showing')} {((page - 1) * 20) + 1} - {Math.min(page * 20, data?.total || 0)} {__('of')} {data?.total || 0} +

+
+ + +
+
+ )} +
+ ); +} diff --git a/customer-spa/src/pages/Account/Licenses.tsx b/customer-spa/src/pages/Account/Licenses.tsx new file mode 100644 index 0000000..07d4c49 --- /dev/null +++ b/customer-spa/src/pages/Account/Licenses.tsx @@ -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(null); + const [expandedLicense, setExpandedLicense] = useState(null); + + const { data: licenses = [], isLoading } = useQuery({ + 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 ( +
+
+
+ ); + } + + return ( +
+
+

+ + My Licenses +

+

+ Manage your software licenses and activations +

+
+ + {licenses.length === 0 ? ( +
+ +

You don't have any licenses yet.

+

+ Purchase a product with licensing to get started. +

+
+ ) : ( +
+ {licenses.map((license) => ( +
+ {/* Header */} +
+
+
+

{license.product_name}

+
+ + {license.license_key} + + +
+
+
+ + {getStatusLabel(license)} + + +
+
+ + {/* Summary Info */} +
+
+

Activations

+

+ {license.activation_count} / {license.activation_limit === 0 ? '∞' : license.activation_limit} +

+
+
+

Purchased

+

+ {new Date(license.created_at).toLocaleDateString()} +

+
+
+

Expires

+

+ {license.expires_at + ? new Date(license.expires_at).toLocaleDateString() + : 'Never'} +

+
+
+
+ + {/* Expanded Content - Activations */} + {expandedLicense === license.id && ( +
+

Active Devices

+ {license.activations.filter(a => a.status === 'active').length === 0 ? ( +

+ No active devices. Activate your license on a device to see it here. +

+ ) : ( +
+ {license.activations + .filter(a => a.status === 'active') + .map((activation) => ( +
+
+ {activation.domain ? ( + <> + + {activation.domain} + + ) : activation.machine_id ? ( + <> + + + {activation.machine_id.substring(0, 16)}... + + + ) : ( + Unknown device + )} + + • {new Date(activation.activated_at).toLocaleDateString()} + +
+ + + + + + + Deactivate Device + + This will deactivate the license on this device. You can reactivate it later if you have available activation slots. + + + + Cancel + deactivateMutation.mutate({ + licenseId: license.id, + activationId: activation.id, + })} + > + Deactivate + + + + +
+ ))} +
+ )} +
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/customer-spa/src/pages/Account/components/AccountLayout.tsx b/customer-spa/src/pages/Account/components/AccountLayout.tsx index 11d9260..493def7 100644 --- a/customer-spa/src/pages/Account/components/AccountLayout.tsx +++ b/customer-spa/src/pages/Account/components/AccountLayout.tsx @@ -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); diff --git a/customer-spa/src/pages/Account/index.tsx b/customer-spa/src/pages/Account/index.tsx index 1a19e98..306f67d 100644 --- a/customer-spa/src/pages/Account/index.tsx +++ b/customer-spa/src/pages/Account/index.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> @@ -37,3 +39,4 @@ export default function Account() { ); } +