- Add findagram.co React frontend with product search, brands, categories - Add findadispo.com React frontend with dispensary locator - Wire findagram to backend /api/az/* endpoints - Update category/brand links to route to /products with filters - Add k8s manifests for both frontends - Add multi-domain user support migrations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
584 lines
22 KiB
TypeScript
584 lines
22 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { Layout } from '../components/Layout';
|
|
import { api } from '../lib/api';
|
|
import { useAuthStore } from '../store/authStore';
|
|
import { Users as UsersIcon, Plus, Pencil, Trash2, X, Check, AlertCircle, Search, Globe, Phone, Mail } from 'lucide-react';
|
|
|
|
interface User {
|
|
id: number;
|
|
email: string;
|
|
role: string;
|
|
first_name: string | null;
|
|
last_name: string | null;
|
|
phone: string | null;
|
|
domain: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface UserFormData {
|
|
email: string;
|
|
password: string;
|
|
role: string;
|
|
first_name: string;
|
|
last_name: string;
|
|
phone: string;
|
|
domain: string;
|
|
}
|
|
|
|
const DOMAINS = [
|
|
{ value: 'cannaiq.co', label: 'CannaIQ' },
|
|
{ value: 'findagram.co', label: 'Find a Gram' },
|
|
{ value: 'findadispo.com', label: 'Find a Dispo' },
|
|
];
|
|
|
|
export function Users() {
|
|
const { user: currentUser } = useAuthStore();
|
|
const [users, setUsers] = useState<User[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
|
const [formData, setFormData] = useState<UserFormData>({
|
|
email: '',
|
|
password: '',
|
|
role: 'viewer',
|
|
first_name: '',
|
|
last_name: '',
|
|
phone: '',
|
|
domain: 'cannaiq.co'
|
|
});
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// Search and filter states
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [domainFilter, setDomainFilter] = useState('');
|
|
|
|
const fetchUsers = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const params = new URLSearchParams();
|
|
if (searchQuery) params.set('search', searchQuery);
|
|
if (domainFilter) params.set('domain', domainFilter);
|
|
|
|
const response = await api.getUsers(params.toString());
|
|
setUsers(response.users);
|
|
setError(null);
|
|
} catch (err: any) {
|
|
setError(err.message || 'Failed to fetch users');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchUsers();
|
|
}, [searchQuery, domainFilter]);
|
|
|
|
const handleCreate = async () => {
|
|
if (!formData.email || !formData.password) {
|
|
setFormError('Email and password are required');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setSaving(true);
|
|
setFormError(null);
|
|
await api.createUser({
|
|
email: formData.email,
|
|
password: formData.password,
|
|
role: formData.role,
|
|
first_name: formData.first_name || undefined,
|
|
last_name: formData.last_name || undefined,
|
|
phone: formData.phone || undefined,
|
|
domain: formData.domain
|
|
});
|
|
setShowCreateModal(false);
|
|
resetForm();
|
|
fetchUsers();
|
|
} catch (err: any) {
|
|
setFormError(err.message || 'Failed to create user');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleUpdate = async () => {
|
|
if (!editingUser) return;
|
|
|
|
const updateData: any = {};
|
|
if (formData.email && formData.email !== editingUser.email) {
|
|
updateData.email = formData.email;
|
|
}
|
|
if (formData.password) {
|
|
updateData.password = formData.password;
|
|
}
|
|
if (formData.role && formData.role !== editingUser.role) {
|
|
updateData.role = formData.role;
|
|
}
|
|
if (formData.first_name !== (editingUser.first_name || '')) {
|
|
updateData.first_name = formData.first_name || null;
|
|
}
|
|
if (formData.last_name !== (editingUser.last_name || '')) {
|
|
updateData.last_name = formData.last_name || null;
|
|
}
|
|
if (formData.phone !== (editingUser.phone || '')) {
|
|
updateData.phone = formData.phone || null;
|
|
}
|
|
if (formData.domain !== editingUser.domain) {
|
|
updateData.domain = formData.domain;
|
|
}
|
|
|
|
if (Object.keys(updateData).length === 0) {
|
|
setEditingUser(null);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setSaving(true);
|
|
setFormError(null);
|
|
await api.updateUser(editingUser.id, updateData);
|
|
setEditingUser(null);
|
|
resetForm();
|
|
fetchUsers();
|
|
} catch (err: any) {
|
|
setFormError(err.message || 'Failed to update user');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (user: User) => {
|
|
if (!confirm(`Are you sure you want to delete ${user.email}?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await api.deleteUser(user.id);
|
|
fetchUsers();
|
|
} catch (err: any) {
|
|
alert(err.message || 'Failed to delete user');
|
|
}
|
|
};
|
|
|
|
const openEditModal = (user: User) => {
|
|
setEditingUser(user);
|
|
setFormData({
|
|
email: user.email,
|
|
password: '',
|
|
role: user.role,
|
|
first_name: user.first_name || '',
|
|
last_name: user.last_name || '',
|
|
phone: user.phone || '',
|
|
domain: user.domain
|
|
});
|
|
setFormError(null);
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setFormData({
|
|
email: '',
|
|
password: '',
|
|
role: 'viewer',
|
|
first_name: '',
|
|
last_name: '',
|
|
phone: '',
|
|
domain: 'cannaiq.co'
|
|
});
|
|
};
|
|
|
|
const closeModal = () => {
|
|
setShowCreateModal(false);
|
|
setEditingUser(null);
|
|
resetForm();
|
|
setFormError(null);
|
|
};
|
|
|
|
const getRoleBadgeColor = (role: string) => {
|
|
switch (role) {
|
|
case 'superadmin':
|
|
return 'bg-purple-100 text-purple-800';
|
|
case 'admin':
|
|
return 'bg-blue-100 text-blue-800';
|
|
case 'analyst':
|
|
return 'bg-green-100 text-green-800';
|
|
case 'viewer':
|
|
return 'bg-gray-100 text-gray-700';
|
|
default:
|
|
return 'bg-gray-100 text-gray-700';
|
|
}
|
|
};
|
|
|
|
const getDomainBadgeColor = (domain: string) => {
|
|
switch (domain) {
|
|
case 'cannaiq.co':
|
|
return 'bg-indigo-100 text-indigo-800';
|
|
case 'findagram.co':
|
|
return 'bg-emerald-100 text-emerald-800';
|
|
case 'findadispo.com':
|
|
return 'bg-amber-100 text-amber-800';
|
|
default:
|
|
return 'bg-gray-100 text-gray-700';
|
|
}
|
|
};
|
|
|
|
const canModifyUser = (user: User) => {
|
|
if (currentUser?.id === user.id) return false;
|
|
if (user.role === 'superadmin' && currentUser?.role !== 'superadmin') return false;
|
|
return true;
|
|
};
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<UsersIcon className="w-8 h-8 text-blue-600" />
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
|
|
<p className="text-sm text-gray-500">Manage system users across all domains</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setShowCreateModal(true);
|
|
setFormError(null);
|
|
}}
|
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Add User
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search and Filters */}
|
|
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
<div className="flex flex-col sm:flex-row gap-4">
|
|
<div className="flex-1 relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search by email or name..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
<div className="sm:w-48">
|
|
<select
|
|
value={domainFilter}
|
|
onChange={(e) => setDomainFilter(e.target.value)}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
>
|
|
<option value="">All Domains</option>
|
|
{DOMAINS.map((d) => (
|
|
<option key={d.value} value={d.value}>{d.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error Message */}
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3">
|
|
<AlertCircle className="w-5 h-5 text-red-500" />
|
|
<p className="text-red-700">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Users Table */}
|
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
|
{loading ? (
|
|
<div className="p-8 text-center">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
|
<p className="mt-2 text-gray-500">Loading users...</p>
|
|
</div>
|
|
) : users.length === 0 ? (
|
|
<div className="p-8 text-center text-gray-500">
|
|
No users found
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
User
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Domain
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Role
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Contact
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Created
|
|
</th>
|
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{users.map((user) => (
|
|
<tr key={user.id} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center">
|
|
<div className="flex-shrink-0 h-10 w-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
|
|
<span className="text-sm font-bold text-white">
|
|
{(user.first_name?.[0] || user.email[0]).toUpperCase()}
|
|
</span>
|
|
</div>
|
|
<div className="ml-3">
|
|
<p className="text-sm font-medium text-gray-900">
|
|
{user.first_name || user.last_name
|
|
? `${user.first_name || ''} ${user.last_name || ''}`.trim()
|
|
: user.email.split('@')[0]}
|
|
</p>
|
|
<p className="text-xs text-gray-500">{user.email}</p>
|
|
{currentUser?.id === user.id && (
|
|
<span className="text-xs text-blue-500">(you)</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-semibold rounded-full ${getDomainBadgeColor(user.domain)}`}>
|
|
<Globe className="w-3 h-3" />
|
|
{DOMAINS.find(d => d.value === user.domain)?.label || user.domain}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRoleBadgeColor(user.role)}`}>
|
|
{user.role}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm">
|
|
{user.phone && (
|
|
<div className="flex items-center gap-1 text-gray-500">
|
|
<Phone className="w-3 h-3" />
|
|
<span>{user.phone}</span>
|
|
</div>
|
|
)}
|
|
{!user.phone && <span className="text-gray-400">-</span>}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
{new Date(user.created_at).toLocaleDateString()}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
{canModifyUser(user) ? (
|
|
<div className="flex items-center justify-end gap-2">
|
|
<button
|
|
onClick={() => openEditModal(user)}
|
|
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
|
title="Edit user"
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(user)}
|
|
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
|
title="Delete user"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<span className="text-xs text-gray-400">-</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Domain Legend */}
|
|
<div className="bg-white rounded-lg border border-gray-200 p-4">
|
|
<h3 className="text-sm font-medium text-gray-700 mb-3">Domains</h3>
|
|
<div className="flex flex-wrap gap-4">
|
|
{DOMAINS.map((domain) => (
|
|
<div key={domain.value} className="flex items-center gap-2">
|
|
<span className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-semibold rounded-full ${getDomainBadgeColor(domain.value)}`}>
|
|
<Globe className="w-3 h-3" />
|
|
{domain.label}
|
|
</span>
|
|
<span className="text-xs text-gray-500">{domain.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Create/Edit Modal */}
|
|
{(showCreateModal || editingUser) && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
|
<h2 className="text-lg font-semibold text-gray-900">
|
|
{editingUser ? 'Edit User' : 'Create New User'}
|
|
</h2>
|
|
<button
|
|
onClick={closeModal}
|
|
className="p-1 text-gray-400 hover:text-gray-600 rounded"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="px-6 py-4 space-y-4">
|
|
{formError && (
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 flex items-center gap-2">
|
|
<AlertCircle className="w-4 h-4 text-red-500" />
|
|
<p className="text-sm text-red-700">{formError}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Profile Section */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
First Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.first_name}
|
|
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder="John"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Last Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.last_name}
|
|
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder="Doe"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
<Mail className="w-4 h-4 inline mr-1" />
|
|
Email
|
|
</label>
|
|
<input
|
|
type="email"
|
|
value={formData.email}
|
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder="user@example.com"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Password {editingUser && <span className="text-gray-400 font-normal">(leave blank to keep current)</span>}
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={formData.password}
|
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder={editingUser ? '********' : 'Enter password'}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
<Phone className="w-4 h-4 inline mr-1" />
|
|
Phone
|
|
</label>
|
|
<input
|
|
type="tel"
|
|
value={formData.phone}
|
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
placeholder="+1 555 123 4567"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
<Globe className="w-4 h-4 inline mr-1" />
|
|
Domain
|
|
</label>
|
|
<select
|
|
value={formData.domain}
|
|
onChange={(e) => setFormData({ ...formData, domain: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
>
|
|
{DOMAINS.map((d) => (
|
|
<option key={d.value} value={d.value}>{d.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Role
|
|
</label>
|
|
<select
|
|
value={formData.role}
|
|
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
>
|
|
<option value="viewer">Viewer</option>
|
|
<option value="analyst">Analyst</option>
|
|
<option value="admin">Admin</option>
|
|
{currentUser?.role === 'superadmin' && (
|
|
<option value="superadmin">Superadmin</option>
|
|
)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50">
|
|
<button
|
|
onClick={closeModal}
|
|
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
|
disabled={saving}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={editingUser ? handleUpdate : handleCreate}
|
|
disabled={saving}
|
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
|
>
|
|
{saving ? (
|
|
<>
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
Saving...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Check className="w-4 h-4" />
|
|
{editingUser ? 'Update' : 'Create'}
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Layout>
|
|
);
|
|
}
|