Files
cannaiq/frontend/src/pages/Users.tsx
Kelly a0f8d3911c feat: Add Findagram and FindADispo consumer frontends
- 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>
2025-12-05 16:10:15 -07:00

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>
);
}