- Add brand promotional history endpoint (GET /api/analytics/v2/brand/:name/promotions) - Tracks when products go on special, duration, discounts, quantity sold estimates - Aggregates by category with frequency metrics (weekly/monthly) - Add quantity changes endpoint (GET /api/analytics/v2/store/:id/quantity-changes) - Filter by direction (increase/decrease/all) for sales vs restock estimation - Fix canonical-upsert to populate stock_quantity and total_quantity_available - Add API key edit functionality in admin UI - Edit allowed domains and IPs - Display domains in list view 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
669 lines
27 KiB
TypeScript
669 lines
27 KiB
TypeScript
import { useEffect, useState, useRef } from 'react';
|
|
import { Layout } from '../components/Layout';
|
|
import { api } from '../lib/api';
|
|
import { Toast } from '../components/Toast';
|
|
import { Key, Plus, Copy, Check, X, Trash2, Power, PowerOff, Store, Globe, Shield, Clock, Eye, EyeOff, Search, ChevronDown, Pencil } from 'lucide-react';
|
|
|
|
interface ApiPermission {
|
|
id: number;
|
|
user_name: string;
|
|
api_key: string;
|
|
allowed_ips: string | null;
|
|
allowed_domains: string | null;
|
|
is_active: number;
|
|
created_at: string;
|
|
last_used_at: string | null;
|
|
store_id: number | null;
|
|
store_name: string | null;
|
|
request_count?: number;
|
|
}
|
|
|
|
interface Dispensary {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
|
|
// Searchable Dropdown Component
|
|
function SearchableSelect({
|
|
options,
|
|
value,
|
|
onChange,
|
|
placeholder = "Select...",
|
|
required = false
|
|
}: {
|
|
options: Dispensary[];
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
placeholder?: string;
|
|
required?: boolean;
|
|
}) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [search, setSearch] = useState('');
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const selectedOption = options.find(o => o.id.toString() === value);
|
|
|
|
const filteredOptions = options.filter(option =>
|
|
option.name.toLowerCase().includes(search.toLowerCase())
|
|
);
|
|
|
|
// Close dropdown when clicking outside
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
setSearch('');
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
// Focus input when dropdown opens
|
|
useEffect(() => {
|
|
if (isOpen && inputRef.current) {
|
|
inputRef.current.focus();
|
|
}
|
|
}, [isOpen]);
|
|
|
|
return (
|
|
<div ref={dropdownRef} className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent bg-white text-left flex items-center justify-between"
|
|
>
|
|
<span className={selectedOption ? 'text-gray-900' : 'text-gray-500'}>
|
|
{selectedOption ? selectedOption.name : placeholder}
|
|
</span>
|
|
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
|
|
{/* Hidden input for form validation */}
|
|
{required && (
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={() => {}}
|
|
required
|
|
className="absolute opacity-0 w-0 h-0"
|
|
tabIndex={-1}
|
|
/>
|
|
)}
|
|
|
|
{isOpen && (
|
|
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-72 overflow-hidden">
|
|
{/* Search Input */}
|
|
<div className="p-2 border-b border-gray-100">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="Type to search dispensaries..."
|
|
className="w-full pl-9 pr-4 py-2 border border-gray-200 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Options List */}
|
|
<div className="max-h-52 overflow-y-auto">
|
|
{filteredOptions.length === 0 ? (
|
|
<div className="px-4 py-3 text-sm text-gray-500 text-center">
|
|
No dispensaries found
|
|
</div>
|
|
) : (
|
|
filteredOptions.slice(0, 100).map((option) => (
|
|
<button
|
|
key={option.id}
|
|
type="button"
|
|
onClick={() => {
|
|
onChange(option.id.toString());
|
|
setIsOpen(false);
|
|
setSearch('');
|
|
}}
|
|
className={`w-full px-4 py-2.5 text-left text-sm hover:bg-emerald-50 transition-colors flex items-center justify-between ${
|
|
value === option.id.toString() ? 'bg-emerald-50 text-emerald-700' : 'text-gray-700'
|
|
}`}
|
|
>
|
|
<span className="truncate">{option.name}</span>
|
|
{value === option.id.toString() && (
|
|
<Check className="w-4 h-4 text-emerald-600 flex-shrink-0" />
|
|
)}
|
|
</button>
|
|
))
|
|
)}
|
|
{filteredOptions.length > 100 && (
|
|
<div className="px-4 py-2 text-xs text-gray-400 text-center border-t">
|
|
Showing first 100 results. Type to narrow down.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ApiPermissions() {
|
|
const [permissions, setPermissions] = useState<ApiPermission[]>([]);
|
|
const [dispensaries, setDispensaries] = useState<Dispensary[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showAddForm, setShowAddForm] = useState(false);
|
|
const [showKeys, setShowKeys] = useState<Record<number, boolean>>({});
|
|
const [copiedId, setCopiedId] = useState<number | null>(null);
|
|
const [newPermission, setNewPermission] = useState({
|
|
user_name: '',
|
|
store_id: '',
|
|
allowed_ips: '',
|
|
allowed_domains: '',
|
|
});
|
|
const [editingPermission, setEditingPermission] = useState<ApiPermission | null>(null);
|
|
const [editForm, setEditForm] = useState({
|
|
user_name: '',
|
|
allowed_ips: '',
|
|
allowed_domains: '',
|
|
});
|
|
const [notification, setNotification] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null);
|
|
|
|
useEffect(() => {
|
|
loadPermissions();
|
|
loadDispensaries();
|
|
}, []);
|
|
|
|
const loadDispensaries = async () => {
|
|
try {
|
|
const data = await api.getApiPermissionDispensaries();
|
|
setDispensaries(data.dispensaries || []);
|
|
} catch (error: any) {
|
|
console.error('Failed to load dispensaries:', error);
|
|
}
|
|
};
|
|
|
|
const loadPermissions = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await api.getApiPermissions();
|
|
setPermissions(data.permissions || []);
|
|
} catch (error: any) {
|
|
setNotification({ message: 'Failed to load API permissions: ' + error.message, type: 'error' });
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAddPermission = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!newPermission.user_name.trim()) {
|
|
setNotification({ message: 'User name is required', type: 'error' });
|
|
return;
|
|
}
|
|
|
|
if (!newPermission.store_id) {
|
|
setNotification({ message: 'Please select a dispensary', type: 'error' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await api.createApiPermission({
|
|
...newPermission,
|
|
store_id: parseInt(newPermission.store_id),
|
|
});
|
|
setNotification({ message: 'API key created successfully! Copy it now - it won\'t be shown again in full.', type: 'success' });
|
|
setNewPermission({ user_name: '', store_id: '', allowed_ips: '', allowed_domains: '' });
|
|
setShowAddForm(false);
|
|
loadPermissions();
|
|
} catch (error: any) {
|
|
setNotification({ message: 'Failed to create permission: ' + error.message, type: 'error' });
|
|
}
|
|
};
|
|
|
|
const handleToggle = async (id: number) => {
|
|
try {
|
|
await api.toggleApiPermission(id);
|
|
setNotification({ message: 'API key status updated', type: 'success' });
|
|
loadPermissions();
|
|
} catch (error: any) {
|
|
setNotification({ message: 'Failed to toggle permission: ' + error.message, type: 'error' });
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: number) => {
|
|
if (!confirm('Are you sure you want to delete this API key? This action cannot be undone.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await api.deleteApiPermission(id);
|
|
setNotification({ message: 'API key deleted successfully', type: 'success' });
|
|
loadPermissions();
|
|
} catch (error: any) {
|
|
setNotification({ message: 'Failed to delete permission: ' + error.message, type: 'error' });
|
|
}
|
|
};
|
|
|
|
const handleEdit = (perm: ApiPermission) => {
|
|
setEditingPermission(perm);
|
|
setEditForm({
|
|
user_name: perm.user_name,
|
|
allowed_ips: perm.allowed_ips || '',
|
|
allowed_domains: perm.allowed_domains || '',
|
|
});
|
|
};
|
|
|
|
const handleSaveEdit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!editingPermission) return;
|
|
|
|
try {
|
|
await api.updateApiPermission(editingPermission.id, {
|
|
user_name: editForm.user_name,
|
|
allowed_ips: editForm.allowed_ips || undefined,
|
|
allowed_domains: editForm.allowed_domains || undefined,
|
|
});
|
|
setNotification({ message: 'API key updated successfully', type: 'success' });
|
|
setEditingPermission(null);
|
|
loadPermissions();
|
|
} catch (error: any) {
|
|
setNotification({ message: 'Failed to update permission: ' + error.message, type: 'error' });
|
|
}
|
|
};
|
|
|
|
const copyToClipboard = async (text: string, id: number) => {
|
|
await navigator.clipboard.writeText(text);
|
|
setCopiedId(id);
|
|
setTimeout(() => setCopiedId(null), 2000);
|
|
};
|
|
|
|
const formatDate = (dateString: string | null) => {
|
|
if (!dateString) return 'Never';
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMs / 3600000);
|
|
const diffDays = Math.floor(diffMs / 86400000);
|
|
|
|
if (diffMins < 1) return 'Just now';
|
|
if (diffMins < 60) return `${diffMins}m ago`;
|
|
if (diffHours < 24) return `${diffHours}h ago`;
|
|
if (diffDays < 7) return `${diffDays}d ago`;
|
|
return date.toLocaleDateString();
|
|
};
|
|
|
|
const toggleShowKey = (id: number) => {
|
|
setShowKeys(prev => ({ ...prev, [id]: !prev[id] }));
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<Layout>
|
|
<div className="p-6">
|
|
<div className="text-center text-gray-600">Loading API keys...</div>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="p-6 max-w-6xl">
|
|
{notification && (
|
|
<Toast
|
|
message={notification.message}
|
|
type={notification.type}
|
|
onClose={() => setNotification(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Header */}
|
|
<div className="flex justify-between items-start mb-8">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
|
<Key className="w-7 h-7 text-emerald-600" />
|
|
WordPress API Keys
|
|
</h1>
|
|
<p className="text-gray-600 mt-2">
|
|
Generate and manage API keys for WordPress plugin integrations
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowAddForm(!showAddForm)}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Generate New Key
|
|
</button>
|
|
</div>
|
|
|
|
{/* WordPress Plugin Instructions */}
|
|
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-xl p-5 mb-6">
|
|
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2">
|
|
<Globe className="w-5 h-5" />
|
|
WordPress Plugin Setup
|
|
</h3>
|
|
<div className="text-sm text-blue-800 space-y-2">
|
|
<p>1. Install the <strong>CannaIQ Menus</strong> plugin on your WordPress site</p>
|
|
<p>2. Generate an API key below for your dispensary</p>
|
|
<p>3. In WordPress, go to <strong>Settings → CannaIQ Menus</strong></p>
|
|
<p>4. Paste your API key and save</p>
|
|
</div>
|
|
<div className="mt-4 pt-4 border-t border-blue-200">
|
|
<p className="text-xs text-blue-600">
|
|
API Endpoint: <code className="bg-blue-100 px-2 py-0.5 rounded">https://api.cannaiq.co/api/v1/products</code>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Add Form */}
|
|
{showAddForm && (
|
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
|
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
|
<Plus className="w-5 h-5 text-emerald-600" />
|
|
Generate New API Key
|
|
</h2>
|
|
<form onSubmit={handleAddPermission} className="space-y-5">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Label / Website Name *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={newPermission.user_name}
|
|
onChange={(e) => setNewPermission({ ...newPermission, user_name: e.target.value })}
|
|
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
|
placeholder="e.g., Main Website, Dev Site"
|
|
required
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">A name to identify this API key</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
<Store className="w-4 h-4 inline mr-1" />
|
|
Dispensary *
|
|
</label>
|
|
<SearchableSelect
|
|
options={dispensaries}
|
|
value={newPermission.store_id}
|
|
onChange={(value) => setNewPermission({ ...newPermission, store_id: value })}
|
|
placeholder="Search for a dispensary..."
|
|
required
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">This key will only access this dispensary's data</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
<Shield className="w-4 h-4 inline mr-1" />
|
|
Allowed IP Addresses (optional)
|
|
</label>
|
|
<textarea
|
|
value={newPermission.allowed_ips}
|
|
onChange={(e) => setNewPermission({ ...newPermission, allowed_ips: e.target.value })}
|
|
rows={3}
|
|
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent font-mono text-sm"
|
|
placeholder="192.168.1.1 10.0.0.0/8"
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">One per line. Leave empty to allow any IP.</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
<Globe className="w-4 h-4 inline mr-1" />
|
|
Allowed Domains (optional)
|
|
</label>
|
|
<textarea
|
|
value={newPermission.allowed_domains}
|
|
onChange={(e) => setNewPermission({ ...newPermission, allowed_domains: e.target.value })}
|
|
rows={3}
|
|
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent font-mono text-sm"
|
|
placeholder="example.com *.example.com"
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">One per line. Wildcards supported.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-3 pt-2">
|
|
<button
|
|
type="submit"
|
|
className="px-5 py-2.5 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors"
|
|
>
|
|
Generate API Key
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAddForm(false)}
|
|
className="px-5 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{/* API Keys List */}
|
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
|
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
|
<h2 className="text-lg font-semibold text-gray-900">Your API Keys</h2>
|
|
<p className="text-sm text-gray-500 mt-0.5">{permissions.length} key{permissions.length !== 1 ? 's' : ''} configured</p>
|
|
</div>
|
|
|
|
{permissions.length === 0 ? (
|
|
<div className="p-12 text-center">
|
|
<Key className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No API keys yet</h3>
|
|
<p className="text-gray-500 mb-4">Generate your first API key to connect your WordPress site</p>
|
|
<button
|
|
onClick={() => setShowAddForm(true)}
|
|
className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Generate API Key
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-100">
|
|
{permissions.map((perm) => (
|
|
<div key={perm.id} className="p-5 hover:bg-gray-50 transition-colors">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<h3 className="font-semibold text-gray-900">{perm.user_name}</h3>
|
|
{perm.is_active ? (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-emerald-100 text-emerald-700">
|
|
<Check className="w-3 h-3" />
|
|
Active
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-red-100 text-red-700">
|
|
<X className="w-3 h-3" />
|
|
Disabled
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4 text-sm text-gray-500 mb-3">
|
|
<span className="flex items-center gap-1">
|
|
<Store className="w-4 h-4" />
|
|
{perm.store_name || <span className="italic">No store assigned</span>}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="w-4 h-4" />
|
|
Last used: {formatDate(perm.last_used_at)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* API Key Display */}
|
|
<div className="flex items-center gap-2 bg-gray-100 rounded-lg p-2.5 max-w-lg">
|
|
<code className="flex-1 text-sm font-mono text-gray-700 truncate">
|
|
{showKeys[perm.id] ? perm.api_key : perm.api_key.substring(0, 8) + '••••••••••••••••' + perm.api_key.substring(perm.api_key.length - 4)}
|
|
</code>
|
|
<button
|
|
onClick={() => toggleShowKey(perm.id)}
|
|
className="p-1.5 text-gray-500 hover:text-gray-700 rounded"
|
|
title={showKeys[perm.id] ? 'Hide' : 'Show'}
|
|
>
|
|
{showKeys[perm.id] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
</button>
|
|
<button
|
|
onClick={() => copyToClipboard(perm.api_key, perm.id)}
|
|
className="p-1.5 text-gray-500 hover:text-emerald-600 rounded"
|
|
title="Copy to clipboard"
|
|
>
|
|
{copiedId === perm.id ? (
|
|
<Check className="w-4 h-4 text-emerald-600" />
|
|
) : (
|
|
<Copy className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Allowed Domains - Always show */}
|
|
<div className="mt-3 text-xs">
|
|
<span className="text-gray-500 flex items-center gap-1">
|
|
<Globe className="w-3 h-3" />
|
|
Domains:{' '}
|
|
{perm.allowed_domains ? (
|
|
<span className="text-gray-700 font-mono">
|
|
{perm.allowed_domains.split('\n').filter(d => d.trim()).join(', ')}
|
|
</span>
|
|
) : (
|
|
<span className="text-amber-600">Any domain (no restriction)</span>
|
|
)}
|
|
</span>
|
|
{perm.allowed_ips && (
|
|
<span className="text-gray-500 ml-4">
|
|
IPs: {perm.allowed_ips.split('\n').filter(ip => ip.trim()).length} allowed
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2 ml-4">
|
|
<button
|
|
onClick={() => handleEdit(perm)}
|
|
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
|
title="Edit"
|
|
>
|
|
<Pencil className="w-5 h-5" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleToggle(perm.id)}
|
|
className={`p-2 rounded-lg transition-colors ${
|
|
perm.is_active
|
|
? 'text-amber-600 hover:bg-amber-50'
|
|
: 'text-emerald-600 hover:bg-emerald-50'
|
|
}`}
|
|
title={perm.is_active ? 'Disable' : 'Enable'}
|
|
>
|
|
{perm.is_active ? <PowerOff className="w-5 h-5" /> : <Power className="w-5 h-5" />}
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(perm.id)}
|
|
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
|
title="Delete"
|
|
>
|
|
<Trash2 className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Edit Modal */}
|
|
{editingPermission && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
|
<div className="px-6 py-4 border-b border-gray-200">
|
|
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
|
<Pencil className="w-5 h-5 text-blue-600" />
|
|
Edit API Key
|
|
</h2>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
{editingPermission.store_name}
|
|
</p>
|
|
</div>
|
|
|
|
<form onSubmit={handleSaveEdit} className="p-6 space-y-5">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Label / Website Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={editForm.user_name}
|
|
onChange={(e) => setEditForm({ ...editForm, user_name: e.target.value })}
|
|
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
<Globe className="w-4 h-4 inline mr-1" />
|
|
Allowed Domains
|
|
</label>
|
|
<textarea
|
|
value={editForm.allowed_domains}
|
|
onChange={(e) => setEditForm({ ...editForm, allowed_domains: e.target.value })}
|
|
rows={4}
|
|
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
|
|
placeholder="example.com *.example.com subdomain.example.com"
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
One domain per line. Use * for wildcards (e.g., *.example.com). Leave empty to allow any domain.
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
<Shield className="w-4 h-4 inline mr-1" />
|
|
Allowed IP Addresses
|
|
</label>
|
|
<textarea
|
|
value={editForm.allowed_ips}
|
|
onChange={(e) => setEditForm({ ...editForm, allowed_ips: e.target.value })}
|
|
rows={3}
|
|
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
|
|
placeholder="192.168.1.1 10.0.0.0/8"
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">One per line. CIDR notation supported. Leave empty to allow any IP.</p>
|
|
</div>
|
|
|
|
<div className="flex gap-3 pt-2">
|
|
<button
|
|
type="submit"
|
|
className="flex-1 px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
|
>
|
|
Save Changes
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditingPermission(null)}
|
|
className="px-5 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|