feat: Implement OAuth license activation flow
- Add LicenseConnect.tsx focused OAuth confirmation page in customer SPA - Add /licenses/oauth/validate and /licenses/oauth/confirm API endpoints - Update App.tsx to render license-connect outside BaseLayout (no header/footer) - Add license_activation_method field to product settings in Admin SPA - Create LICENSING_MODULE.md with comprehensive OAuth flow documentation - Update API_ROUTES.md with license module endpoints
This commit is contained in:
289
customer-spa/src/pages/Account/LicenseConnect.tsx
Normal file
289
customer-spa/src/pages/Account/LicenseConnect.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { api } from '@/lib/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Shield, Globe, Key, Check, X, Loader2, AlertTriangle } from 'lucide-react';
|
||||
|
||||
export default function LicenseConnect() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [licenseInfo, setLicenseInfo] = useState<any>(null);
|
||||
|
||||
// Get params from URL
|
||||
const licenseKey = searchParams.get('license_key') || '';
|
||||
const siteUrl = searchParams.get('site_url') || '';
|
||||
const returnUrl = searchParams.get('return_url') || '';
|
||||
const state = searchParams.get('state') || '';
|
||||
const nonce = searchParams.get('nonce') || '';
|
||||
|
||||
// Get site name from window
|
||||
const siteName = (window as any).woonoowCustomer?.siteName || 'WooNooW';
|
||||
|
||||
// Validate and load license info
|
||||
useEffect(() => {
|
||||
if (!licenseKey || !siteUrl || !state) {
|
||||
setError('Invalid license connection request. Missing required parameters.');
|
||||
return;
|
||||
}
|
||||
|
||||
const loadLicenseInfo = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get(`/licenses/oauth/validate?license_key=${encodeURIComponent(licenseKey)}&state=${encodeURIComponent(state)}`);
|
||||
setLicenseInfo(response);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to validate license connection request.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadLicenseInfo();
|
||||
}, [licenseKey, siteUrl, state]);
|
||||
|
||||
// Handle confirmation
|
||||
const handleConfirm = async () => {
|
||||
setConfirming(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api.post<{ success?: boolean; redirect_url?: string }>('/licenses/oauth/confirm', {
|
||||
license_key: licenseKey,
|
||||
site_url: siteUrl,
|
||||
state: state,
|
||||
nonce: nonce,
|
||||
});
|
||||
|
||||
if (response.success && response.redirect_url) {
|
||||
// Redirect to return URL with activation token
|
||||
window.location.href = response.redirect_url;
|
||||
} else {
|
||||
setSuccess(true);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to confirm license activation.');
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle cancel
|
||||
const handleCancel = () => {
|
||||
if (returnUrl) {
|
||||
window.location.href = `${returnUrl}?error=cancelled&message=User%20cancelled%20the%20license%20activation`;
|
||||
} else {
|
||||
navigate('/my-account/licenses');
|
||||
}
|
||||
};
|
||||
|
||||
// Full-page focused container
|
||||
const PageWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col">
|
||||
{/* Minimal header with brand */}
|
||||
<header className="py-6 px-8 flex justify-center">
|
||||
<div className="text-xl font-bold text-slate-900">{siteName}</div>
|
||||
</header>
|
||||
|
||||
{/* Centered content */}
|
||||
<main className="flex-1 flex items-center justify-center px-4 pb-12">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Minimal footer */}
|
||||
<footer className="py-4 text-center text-sm text-slate-500">
|
||||
Secure License Activation
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render error state (when no license info is loaded)
|
||||
if (error && !licenseInfo) {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<div className="h-16 w-16 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<X className="h-8 w-8 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-center text-slate-900 mb-2">
|
||||
Connection Error
|
||||
</h1>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700 text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-8 py-4 bg-slate-50 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => navigate('/my-account/licenses')}
|
||||
>
|
||||
Back to My Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Render loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-12 flex flex-col items-center justify-center">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-blue-600 mb-4" />
|
||||
<p className="text-slate-600 font-medium">Validating license request...</p>
|
||||
<p className="text-slate-400 text-sm mt-1">Please wait</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Render success state
|
||||
if (success) {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<div className="h-16 w-16 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<Check className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-center text-slate-900 mb-2">
|
||||
License Activated!
|
||||
</h1>
|
||||
<p className="text-slate-600 text-center">
|
||||
Your license has been successfully activated for the specified site.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-8 py-4 bg-slate-50 border-t">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => navigate('/my-account/licenses')}
|
||||
>
|
||||
View My Licenses
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Render confirmation page
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="w-full max-w-lg">
|
||||
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-8 text-center border-b bg-gradient-to-b from-blue-50 to-white">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="h-20 w-20 rounded-full bg-blue-100 flex items-center justify-center">
|
||||
<Shield className="h-10 w-10 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Activate Your License</h1>
|
||||
<p className="text-slate-500 mt-2">
|
||||
A site is requesting to activate your license
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-8 space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* License Info Cards */}
|
||||
<div className="space-y-3">
|
||||
<div className="bg-slate-50 rounded-xl p-4 flex items-start gap-4">
|
||||
<div className="h-10 w-10 rounded-lg bg-white shadow-sm flex items-center justify-center shrink-0">
|
||||
<Key className="h-5 w-5 text-slate-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-slate-900">License Key</p>
|
||||
<p className="text-slate-500 font-mono text-sm truncate">{licenseKey}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 rounded-xl p-4 flex items-start gap-4">
|
||||
<div className="h-10 w-10 rounded-lg bg-white shadow-sm flex items-center justify-center shrink-0">
|
||||
<Globe className="h-5 w-5 text-slate-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-slate-900">Requesting Site</p>
|
||||
<p className="text-slate-500 text-sm truncate">{siteUrl}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{licenseInfo?.product_name && (
|
||||
<div className="bg-slate-50 rounded-xl p-4 flex items-start gap-4">
|
||||
<div className="h-10 w-10 rounded-lg bg-white shadow-sm flex items-center justify-center shrink-0">
|
||||
<Shield className="h-5 w-5 text-slate-600" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-slate-900">Product</p>
|
||||
<p className="text-slate-500 text-sm">{licenseInfo.product_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 flex gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-amber-800">
|
||||
By confirming, you authorize this site to use your license.
|
||||
Only confirm if you trust the requesting site.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="px-8 py-5 bg-slate-50 border-t flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-12"
|
||||
onClick={handleCancel}
|
||||
disabled={confirming}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 h-12 bg-blue-600 hover:bg-blue-700"
|
||||
onClick={handleConfirm}
|
||||
disabled={confirming}
|
||||
>
|
||||
{confirming ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Activating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Authorize
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import Addresses from './Addresses';
|
||||
import Wishlist from './Wishlist';
|
||||
import AccountDetails from './AccountDetails';
|
||||
import Licenses from './Licenses';
|
||||
import LicenseConnect from './LicenseConnect';
|
||||
import Subscriptions from './Subscriptions';
|
||||
import SubscriptionDetail from './SubscriptionDetail';
|
||||
|
||||
@@ -19,10 +20,15 @@ export default function Account() {
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!user?.isLoggedIn) {
|
||||
const currentPath = location.pathname;
|
||||
const currentPath = location.pathname + location.search;
|
||||
return <Navigate to={`/login?redirect=${encodeURIComponent(currentPath)}`} replace />;
|
||||
}
|
||||
|
||||
// Check if this is the license-connect route (render without AccountLayout)
|
||||
if (location.pathname.includes('/license-connect')) {
|
||||
return <LicenseConnect />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<AccountLayout>
|
||||
@@ -43,4 +49,3 @@ export default function Account() {
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user