Files
cannaiq/cannaiq/src/pages/ApiPermissions.tsx
Kelly 95792aab15 feat(analytics): Brand promotional history + specials fix + API key editing
- 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>
2025-12-10 10:59:03 -07:00

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;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&#10;*.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&#10;*.example.com&#10;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;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>
);
}