Create shared getVehicleLabel/getVehicleSubtitle in core/utils with VehicleLike interface. Replace all direct year/make/model concatenation across 17 consumer files to prevent null values in vehicle names. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
920 lines
33 KiB
TypeScript
920 lines
33 KiB
TypeScript
/**
|
|
* @ai-summary Mobile admin screen for user management
|
|
* @ai-context List users, search, filter, change tiers, deactivate/reactivate
|
|
*/
|
|
|
|
import React, { useState, useCallback } from 'react';
|
|
import { Navigate } from 'react-router-dom';
|
|
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
|
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
|
import { useAdminAccess } from '../../../core/auth/useAdminAccess';
|
|
import {
|
|
useUsers,
|
|
useUpdateUserTier,
|
|
useDeactivateUser,
|
|
useReactivateUser,
|
|
useUpdateUserProfile,
|
|
usePromoteToAdmin,
|
|
useHardDeleteUser,
|
|
useAdminStats,
|
|
useUserVehicles,
|
|
} from '../hooks/useUsers';
|
|
import {
|
|
ManagedUser,
|
|
SubscriptionTier,
|
|
ListUsersParams,
|
|
} from '../types/admin.types';
|
|
import { getVehicleSubtitle } from '@/core/utils/vehicleDisplay';
|
|
|
|
// Modal component for dialogs
|
|
interface ModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
title: string;
|
|
children: React.ReactNode;
|
|
actions?: React.ReactNode;
|
|
}
|
|
|
|
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, actions }) => {
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-white dark:bg-scuro rounded-xl p-6 max-w-sm w-full shadow-xl">
|
|
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-4">{title}</h3>
|
|
{children}
|
|
<div className="flex justify-end gap-2 mt-4">
|
|
{actions || (
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
|
>
|
|
Close
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Tier badge component
|
|
const TierBadge: React.FC<{ tier: SubscriptionTier }> = ({ tier }) => {
|
|
const colors: Record<SubscriptionTier, string> = {
|
|
free: 'bg-gray-100 text-gray-700',
|
|
pro: 'bg-blue-100 text-blue-700',
|
|
enterprise: 'bg-purple-100 text-purple-700',
|
|
};
|
|
|
|
return (
|
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colors[tier]}`}>
|
|
{tier.charAt(0).toUpperCase() + tier.slice(1)}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
// Status badge component
|
|
const StatusBadge: React.FC<{ active: boolean }> = ({ active }) => (
|
|
<span
|
|
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
|
active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
|
}`}
|
|
>
|
|
{active ? 'Active' : 'Deactivated'}
|
|
</span>
|
|
);
|
|
|
|
// Vehicle count badge component
|
|
const VehicleCountBadge: React.FC<{ count: number; onClick?: () => void }> = ({ count, onClick }) => (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onClick?.();
|
|
}}
|
|
disabled={count === 0 || !onClick}
|
|
className={`px-2 py-1 rounded-full text-xs font-medium bg-slate-100 text-slate-700 flex items-center gap-1 ${count > 0 && onClick ? 'cursor-pointer' : ''}`}
|
|
>
|
|
{count} {count === 1 ? 'vehicle' : 'vehicles'}
|
|
{count > 0 && onClick && (
|
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
);
|
|
|
|
// Expandable vehicle list component
|
|
const UserVehiclesList: React.FC<{ auth0Sub: string; isOpen: boolean }> = ({ auth0Sub, isOpen }) => {
|
|
const { data, isLoading, error } = useUserVehicles(auth0Sub);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div className="mt-3 pt-3 border-t border-slate-200 dark:border-silverstone">
|
|
<p className="text-xs font-semibold text-slate-500 dark:text-silverstone uppercase mb-2 flex items-center gap-1">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 4H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h4l3 3 3-3h4c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM8 11V9h8v2m-8 4v-2h5v2" />
|
|
</svg>
|
|
Vehicles
|
|
</p>
|
|
{isLoading ? (
|
|
<div className="flex justify-center py-2">
|
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600" />
|
|
</div>
|
|
) : error ? (
|
|
<p className="text-xs text-red-500">Failed to load vehicles</p>
|
|
) : !data?.vehicles?.length ? (
|
|
<p className="text-xs text-slate-400">No vehicles registered</p>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{data.vehicles.map((vehicle, idx) => (
|
|
<div key={idx} className="text-sm text-slate-600 dark:text-silverstone bg-slate-50 dark:bg-carbon px-2 py-1 rounded">
|
|
{getVehicleSubtitle(vehicle) || 'Unknown Vehicle'}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const AdminUsersMobileScreen: React.FC = () => {
|
|
const { isAdmin, loading: adminLoading } = useAdminAccess();
|
|
|
|
// Filter state
|
|
const [params, setParams] = useState<ListUsersParams>({
|
|
page: 1,
|
|
pageSize: 20,
|
|
status: 'all',
|
|
sortBy: 'createdAt',
|
|
sortOrder: 'desc',
|
|
});
|
|
const [searchInput, setSearchInput] = useState('');
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
|
|
// Query
|
|
const { data, isLoading, error, refetch } = useUsers(params);
|
|
|
|
// Admin stats
|
|
const { data: statsData } = useAdminStats();
|
|
|
|
// Expanded user for vehicle list
|
|
const [expandedUserId, setExpandedUserId] = useState<string | null>(null);
|
|
|
|
// Mutations
|
|
const updateTierMutation = useUpdateUserTier();
|
|
const deactivateMutation = useDeactivateUser();
|
|
const reactivateMutation = useReactivateUser();
|
|
const updateProfileMutation = useUpdateUserProfile();
|
|
const promoteToAdminMutation = usePromoteToAdmin();
|
|
const hardDeleteMutation = useHardDeleteUser();
|
|
|
|
// Selected user for actions
|
|
const [selectedUser, setSelectedUser] = useState<ManagedUser | null>(null);
|
|
const [showUserActions, setShowUserActions] = useState(false);
|
|
const [showTierPicker, setShowTierPicker] = useState(false);
|
|
const [showDeactivateConfirm, setShowDeactivateConfirm] = useState(false);
|
|
const [deactivateReason, setDeactivateReason] = useState('');
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
|
const [editEmail, setEditEmail] = useState('');
|
|
const [editDisplayName, setEditDisplayName] = useState('');
|
|
const [showPromoteModal, setShowPromoteModal] = useState(false);
|
|
const [promoteRole, setPromoteRole] = useState<'admin' | 'super_admin'>('admin');
|
|
const [showHardDeleteModal, setShowHardDeleteModal] = useState(false);
|
|
const [hardDeleteReason, setHardDeleteReason] = useState('');
|
|
const [hardDeleteConfirmText, setHardDeleteConfirmText] = useState('');
|
|
|
|
// Handlers
|
|
const handleSearch = useCallback(() => {
|
|
setParams(prev => ({ ...prev, search: searchInput || undefined, page: 1 }));
|
|
}, [searchInput]);
|
|
|
|
const handleClearSearch = useCallback(() => {
|
|
setSearchInput('');
|
|
setParams(prev => ({ ...prev, search: undefined, page: 1 }));
|
|
}, []);
|
|
|
|
const handleTierFilterChange = useCallback((tier: SubscriptionTier | '') => {
|
|
setParams(prev => ({
|
|
...prev,
|
|
tier: tier || undefined,
|
|
page: 1,
|
|
}));
|
|
}, []);
|
|
|
|
const handleStatusFilterChange = useCallback((status: 'active' | 'deactivated' | 'all') => {
|
|
setParams(prev => ({ ...prev, status, page: 1 }));
|
|
}, []);
|
|
|
|
const handleUserClick = useCallback((user: ManagedUser) => {
|
|
setSelectedUser(user);
|
|
setShowUserActions(true);
|
|
}, []);
|
|
|
|
const handleTierChange = useCallback(
|
|
(newTier: SubscriptionTier) => {
|
|
if (selectedUser) {
|
|
updateTierMutation.mutate(
|
|
{ auth0Sub: selectedUser.auth0Sub, data: { subscriptionTier: newTier } },
|
|
{
|
|
onSuccess: () => {
|
|
setShowTierPicker(false);
|
|
setShowUserActions(false);
|
|
setSelectedUser(null);
|
|
},
|
|
}
|
|
);
|
|
}
|
|
},
|
|
[selectedUser, updateTierMutation]
|
|
);
|
|
|
|
const handleDeactivate = useCallback(() => {
|
|
if (selectedUser) {
|
|
deactivateMutation.mutate(
|
|
{ auth0Sub: selectedUser.auth0Sub, data: { reason: deactivateReason || undefined } },
|
|
{
|
|
onSuccess: () => {
|
|
setShowDeactivateConfirm(false);
|
|
setShowUserActions(false);
|
|
setDeactivateReason('');
|
|
setSelectedUser(null);
|
|
},
|
|
}
|
|
);
|
|
}
|
|
}, [selectedUser, deactivateReason, deactivateMutation]);
|
|
|
|
const handleReactivate = useCallback(() => {
|
|
if (selectedUser) {
|
|
reactivateMutation.mutate(selectedUser.auth0Sub, {
|
|
onSuccess: () => {
|
|
setShowUserActions(false);
|
|
setSelectedUser(null);
|
|
},
|
|
});
|
|
}
|
|
}, [selectedUser, reactivateMutation]);
|
|
|
|
const handleEditClick = useCallback(() => {
|
|
if (selectedUser) {
|
|
setEditEmail(selectedUser.email);
|
|
setEditDisplayName(selectedUser.displayName || '');
|
|
setShowUserActions(false);
|
|
setShowEditModal(true);
|
|
}
|
|
}, [selectedUser]);
|
|
|
|
const handleEditConfirm = useCallback(() => {
|
|
if (selectedUser) {
|
|
const updates: { email?: string; displayName?: string } = {};
|
|
if (editEmail !== selectedUser.email) {
|
|
updates.email = editEmail;
|
|
}
|
|
if (editDisplayName !== (selectedUser.displayName || '')) {
|
|
updates.displayName = editDisplayName;
|
|
}
|
|
if (Object.keys(updates).length > 0) {
|
|
updateProfileMutation.mutate(
|
|
{ auth0Sub: selectedUser.auth0Sub, data: updates },
|
|
{
|
|
onSuccess: () => {
|
|
setShowEditModal(false);
|
|
setEditEmail('');
|
|
setEditDisplayName('');
|
|
setSelectedUser(null);
|
|
},
|
|
}
|
|
);
|
|
}
|
|
}
|
|
}, [selectedUser, editEmail, editDisplayName, updateProfileMutation]);
|
|
|
|
const handleEditCancel = useCallback(() => {
|
|
setShowEditModal(false);
|
|
setEditEmail('');
|
|
setEditDisplayName('');
|
|
setSelectedUser(null);
|
|
}, []);
|
|
|
|
const handlePromoteClick = useCallback(() => {
|
|
setPromoteRole('admin');
|
|
setShowUserActions(false);
|
|
setShowPromoteModal(true);
|
|
}, []);
|
|
|
|
const handlePromoteConfirm = useCallback(() => {
|
|
if (selectedUser) {
|
|
promoteToAdminMutation.mutate(
|
|
{ auth0Sub: selectedUser.auth0Sub, data: { role: promoteRole } },
|
|
{
|
|
onSuccess: () => {
|
|
setShowPromoteModal(false);
|
|
setPromoteRole('admin');
|
|
setSelectedUser(null);
|
|
},
|
|
}
|
|
);
|
|
}
|
|
}, [selectedUser, promoteRole, promoteToAdminMutation]);
|
|
|
|
const handlePromoteCancel = useCallback(() => {
|
|
setShowPromoteModal(false);
|
|
setPromoteRole('admin');
|
|
setSelectedUser(null);
|
|
}, []);
|
|
|
|
const handleHardDeleteClick = useCallback(() => {
|
|
setShowUserActions(false);
|
|
setShowHardDeleteModal(true);
|
|
}, []);
|
|
|
|
const handleHardDeleteConfirm = useCallback(() => {
|
|
if (selectedUser && hardDeleteConfirmText === 'DELETE') {
|
|
hardDeleteMutation.mutate(
|
|
{ auth0Sub: selectedUser.auth0Sub, reason: hardDeleteReason || undefined },
|
|
{
|
|
onSuccess: () => {
|
|
setShowHardDeleteModal(false);
|
|
setHardDeleteReason('');
|
|
setHardDeleteConfirmText('');
|
|
setSelectedUser(null);
|
|
},
|
|
}
|
|
);
|
|
}
|
|
}, [selectedUser, hardDeleteReason, hardDeleteConfirmText, hardDeleteMutation]);
|
|
|
|
const handleHardDeleteCancel = useCallback(() => {
|
|
setShowHardDeleteModal(false);
|
|
setHardDeleteReason('');
|
|
setHardDeleteConfirmText('');
|
|
setSelectedUser(null);
|
|
}, []);
|
|
|
|
const handleLoadMore = useCallback(() => {
|
|
setParams(prev => ({ ...prev, page: (prev.page || 1) + 1 }));
|
|
}, []);
|
|
|
|
// Loading state
|
|
if (adminLoading) {
|
|
return (
|
|
<MobileContainer>
|
|
<div className="flex items-center justify-center min-h-[400px]">
|
|
<div className="text-center">
|
|
<div className="text-slate-500 mb-2">Loading admin access...</div>
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto" />
|
|
</div>
|
|
</div>
|
|
</MobileContainer>
|
|
);
|
|
}
|
|
|
|
// Not admin redirect
|
|
if (!isAdmin) {
|
|
return <Navigate to="/garage/settings" replace />;
|
|
}
|
|
|
|
const users = data?.users || [];
|
|
const total = data?.total || 0;
|
|
const hasMore = users.length < total;
|
|
|
|
return (
|
|
<MobileContainer>
|
|
<div className="space-y-4 pb-20 p-4">
|
|
{/* Header */}
|
|
<div className="text-center mb-6">
|
|
<h1 className="text-2xl font-bold text-slate-800 dark:text-avus">User Management</h1>
|
|
<div className="flex justify-center gap-4 mt-3">
|
|
<div className="bg-blue-50 dark:bg-blue-900/30 px-4 py-2 rounded-lg">
|
|
<p className="text-xl font-bold text-blue-700 dark:text-blue-400">{statsData?.totalUsers ?? total}</p>
|
|
<p className="text-xs text-blue-600 dark:text-blue-300">Users</p>
|
|
</div>
|
|
<div className="bg-green-50 dark:bg-green-900/30 px-4 py-2 rounded-lg">
|
|
<p className="text-xl font-bold text-green-700 dark:text-green-400">{statsData?.totalVehicles ?? 0}</p>
|
|
<p className="text-xs text-green-600 dark:text-green-300">Vehicles</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search Bar */}
|
|
<GlassCard padding="sm">
|
|
<div className="flex gap-2">
|
|
<div className="flex-1 relative">
|
|
<input
|
|
type="text"
|
|
placeholder="Search by email or name..."
|
|
value={searchInput}
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
|
className="w-full px-4 py-3 rounded-lg border border-slate-200 dark:border-silverstone dark:bg-scuro dark:text-avus focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[44px]"
|
|
/>
|
|
{searchInput && (
|
|
<button
|
|
onClick={handleClearSearch}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={handleSearch}
|
|
className="px-4 py-3 bg-blue-600 text-white rounded-lg font-medium min-h-[44px]"
|
|
>
|
|
Search
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filter Toggle */}
|
|
<button
|
|
onClick={() => setShowFilters(!showFilters)}
|
|
className="mt-3 text-blue-600 text-sm font-medium flex items-center gap-1 min-h-[44px]"
|
|
>
|
|
<svg className={`w-4 h-4 transition-transform ${showFilters ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
{showFilters ? 'Hide Filters' : 'Show Filters'}
|
|
</button>
|
|
|
|
{/* Filters */}
|
|
{showFilters && (
|
|
<div className="mt-3 pt-3 border-t border-slate-200 space-y-3">
|
|
{/* Tier Filter */}
|
|
<div>
|
|
<label className="text-sm font-medium text-slate-600 block mb-1">Tier</label>
|
|
<select
|
|
value={params.tier || ''}
|
|
onChange={(e) => handleTierFilterChange(e.target.value as SubscriptionTier | '')}
|
|
className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-silverstone dark:bg-scuro dark:text-avus min-h-[44px]"
|
|
>
|
|
<option value="">All Tiers</option>
|
|
<option value="free">Free</option>
|
|
<option value="pro">Pro</option>
|
|
<option value="enterprise">Enterprise</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Status Filter */}
|
|
<div>
|
|
<label className="text-sm font-medium text-slate-600 block mb-1">Status</label>
|
|
<select
|
|
value={params.status || 'all'}
|
|
onChange={(e) => handleStatusFilterChange(e.target.value as 'active' | 'deactivated' | 'all')}
|
|
className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-silverstone dark:bg-scuro dark:text-avus min-h-[44px]"
|
|
>
|
|
<option value="all">All</option>
|
|
<option value="active">Active</option>
|
|
<option value="deactivated">Deactivated</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</GlassCard>
|
|
|
|
{/* Loading State */}
|
|
{isLoading && users.length === 0 && (
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Error State */}
|
|
{error && (
|
|
<GlassCard padding="md">
|
|
<div className="text-center text-red-600">
|
|
<p>Failed to load users. Please try again.</p>
|
|
<button
|
|
onClick={() => refetch()}
|
|
className="mt-2 text-blue-600 font-medium min-h-[44px]"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</GlassCard>
|
|
)}
|
|
|
|
{/* Empty State */}
|
|
{!isLoading && !error && users.length === 0 && (
|
|
<GlassCard padding="md">
|
|
<div className="text-center text-slate-500">
|
|
<p>No users found matching your criteria.</p>
|
|
</div>
|
|
</GlassCard>
|
|
)}
|
|
|
|
{/* User List */}
|
|
{users.length > 0 && (
|
|
<div className="space-y-3">
|
|
{users.map((user) => (
|
|
<GlassCard key={user.auth0Sub} padding="md">
|
|
<button
|
|
onClick={() => handleUserClick(user)}
|
|
className="w-full text-left min-h-[44px]"
|
|
style={{ opacity: user.deactivatedAt ? 0.6 : 1 }}
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-slate-800 dark:text-avus truncate">{user.email}</p>
|
|
{user.displayName && (
|
|
<p className="text-sm text-slate-500 dark:text-silverstone truncate">{user.displayName}</p>
|
|
)}
|
|
<div className="flex flex-wrap gap-2 mt-2">
|
|
<TierBadge tier={user.subscriptionTier} />
|
|
<VehicleCountBadge
|
|
count={user.vehicleCount}
|
|
onClick={user.vehicleCount > 0 ? () => setExpandedUserId(
|
|
expandedUserId === user.auth0Sub ? null : user.auth0Sub
|
|
) : undefined}
|
|
/>
|
|
<StatusBadge active={!user.deactivatedAt} />
|
|
{user.isAdmin && (
|
|
<span className="px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700">
|
|
Admin
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<svg className="w-5 h-5 text-slate-400 flex-shrink-0 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</div>
|
|
</button>
|
|
<UserVehiclesList
|
|
auth0Sub={user.auth0Sub}
|
|
isOpen={expandedUserId === user.auth0Sub}
|
|
/>
|
|
</GlassCard>
|
|
))}
|
|
|
|
{/* Load More */}
|
|
{hasMore && (
|
|
<button
|
|
onClick={handleLoadMore}
|
|
disabled={isLoading}
|
|
className="w-full py-3 text-blue-600 font-medium min-h-[44px]"
|
|
>
|
|
{isLoading ? 'Loading...' : 'Load More'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* User Actions Modal */}
|
|
<Modal
|
|
isOpen={showUserActions}
|
|
onClose={() => {
|
|
setShowUserActions(false);
|
|
setSelectedUser(null);
|
|
}}
|
|
title="User Actions"
|
|
actions={
|
|
<button
|
|
onClick={() => {
|
|
setShowUserActions(false);
|
|
setSelectedUser(null);
|
|
}}
|
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
|
>
|
|
Cancel
|
|
</button>
|
|
}
|
|
>
|
|
{selectedUser && (
|
|
<div className="space-y-3">
|
|
<div className="text-sm text-slate-600">
|
|
<p className="font-medium">{selectedUser.email}</p>
|
|
<div className="flex gap-2 mt-1">
|
|
<TierBadge tier={selectedUser.subscriptionTier} />
|
|
<StatusBadge active={!selectedUser.deactivatedAt} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-slate-200 pt-3 space-y-2">
|
|
<button
|
|
onClick={handleEditClick}
|
|
className="w-full py-3 text-left text-blue-600 font-medium min-h-[44px]"
|
|
>
|
|
Edit User
|
|
</button>
|
|
|
|
{!selectedUser.isAdmin && (
|
|
<button
|
|
onClick={handlePromoteClick}
|
|
className="w-full py-3 text-left text-purple-600 font-medium min-h-[44px]"
|
|
>
|
|
Promote to Admin
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
onClick={() => {
|
|
setShowUserActions(false);
|
|
setShowTierPicker(true);
|
|
}}
|
|
disabled={!!selectedUser.deactivatedAt}
|
|
className="w-full py-3 text-left text-blue-600 font-medium disabled:text-slate-300 min-h-[44px]"
|
|
>
|
|
Change Subscription Tier
|
|
</button>
|
|
|
|
{selectedUser.deactivatedAt ? (
|
|
<button
|
|
onClick={handleReactivate}
|
|
disabled={reactivateMutation.isPending}
|
|
className="w-full py-3 text-left text-green-600 font-medium disabled:opacity-50 min-h-[44px]"
|
|
>
|
|
{reactivateMutation.isPending ? 'Reactivating...' : 'Reactivate User'}
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => {
|
|
setShowUserActions(false);
|
|
setShowDeactivateConfirm(true);
|
|
}}
|
|
className="w-full py-3 text-left text-red-600 font-medium min-h-[44px]"
|
|
>
|
|
Deactivate User
|
|
</button>
|
|
)}
|
|
|
|
{!selectedUser.isAdmin && (
|
|
<button
|
|
onClick={handleHardDeleteClick}
|
|
className="w-full py-3 text-left text-red-600 font-medium min-h-[44px]"
|
|
>
|
|
Delete Permanently
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* Tier Picker Modal */}
|
|
<Modal
|
|
isOpen={showTierPicker}
|
|
onClose={() => setShowTierPicker(false)}
|
|
title="Change Subscription Tier"
|
|
actions={
|
|
<button
|
|
onClick={() => setShowTierPicker(false)}
|
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
|
>
|
|
Cancel
|
|
</button>
|
|
}
|
|
>
|
|
<div className="space-y-2">
|
|
{(['free', 'pro', 'enterprise'] as SubscriptionTier[]).map((tier) => (
|
|
<button
|
|
key={tier}
|
|
onClick={() => handleTierChange(tier)}
|
|
disabled={updateTierMutation.isPending || tier === selectedUser?.subscriptionTier}
|
|
className={`w-full py-3 px-4 rounded-lg text-left font-medium min-h-[44px] ${
|
|
tier === selectedUser?.subscriptionTier
|
|
? 'bg-blue-100 text-blue-700'
|
|
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
|
} disabled:opacity-50`}
|
|
>
|
|
{tier.charAt(0).toUpperCase() + tier.slice(1)}
|
|
{tier === selectedUser?.subscriptionTier && ' (Current)'}
|
|
</button>
|
|
))}
|
|
{updateTierMutation.isPending && (
|
|
<p className="text-center text-sm text-slate-500">Updating...</p>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Deactivate Confirmation Modal */}
|
|
<Modal
|
|
isOpen={showDeactivateConfirm}
|
|
onClose={() => {
|
|
setShowDeactivateConfirm(false);
|
|
setDeactivateReason('');
|
|
}}
|
|
title="Deactivate User"
|
|
actions={
|
|
<>
|
|
<button
|
|
onClick={() => {
|
|
setShowDeactivateConfirm(false);
|
|
setDeactivateReason('');
|
|
}}
|
|
disabled={deactivateMutation.isPending}
|
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleDeactivate}
|
|
disabled={deactivateMutation.isPending}
|
|
className="px-4 py-2 bg-red-600 text-white rounded-lg font-medium min-h-[44px] disabled:opacity-50"
|
|
>
|
|
{deactivateMutation.isPending ? 'Deactivating...' : 'Deactivate'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-3">
|
|
<p className="text-slate-600">
|
|
Are you sure you want to deactivate{' '}
|
|
<strong>{selectedUser?.email}</strong>?
|
|
</p>
|
|
<p className="text-sm text-slate-500">
|
|
The user will no longer be able to log in, but their data will be preserved.
|
|
</p>
|
|
<div>
|
|
<label className="text-sm font-medium text-slate-600 block mb-1">
|
|
Reason (optional)
|
|
</label>
|
|
<textarea
|
|
value={deactivateReason}
|
|
onChange={(e) => setDeactivateReason(e.target.value)}
|
|
placeholder="Enter a reason for deactivation..."
|
|
className="w-full px-3 py-2 rounded-lg border border-slate-200 resize-none"
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Edit User Modal */}
|
|
<Modal
|
|
isOpen={showEditModal}
|
|
onClose={() => !updateProfileMutation.isPending && handleEditCancel()}
|
|
title="Edit User"
|
|
actions={
|
|
<>
|
|
<button
|
|
onClick={handleEditCancel}
|
|
disabled={updateProfileMutation.isPending}
|
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleEditConfirm}
|
|
disabled={
|
|
updateProfileMutation.isPending ||
|
|
(editEmail === selectedUser?.email && editDisplayName === (selectedUser?.displayName || ''))
|
|
}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg font-medium min-h-[44px] disabled:opacity-50"
|
|
>
|
|
{updateProfileMutation.isPending ? 'Saving...' : 'Save'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="text-sm font-medium text-slate-600 block mb-1">Email</label>
|
|
<input
|
|
type="email"
|
|
value={editEmail}
|
|
onChange={(e) => setEditEmail(e.target.value)}
|
|
className="w-full px-3 py-2 rounded-lg border border-slate-200 min-h-[44px]"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium text-slate-600 block mb-1">Display Name</label>
|
|
<input
|
|
type="text"
|
|
value={editDisplayName}
|
|
onChange={(e) => setEditDisplayName(e.target.value)}
|
|
placeholder="Enter display name..."
|
|
className="w-full px-3 py-2 rounded-lg border border-slate-200 min-h-[44px]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Promote to Admin Modal */}
|
|
<Modal
|
|
isOpen={showPromoteModal}
|
|
onClose={() => !promoteToAdminMutation.isPending && handlePromoteCancel()}
|
|
title="Promote to Admin"
|
|
actions={
|
|
<>
|
|
<button
|
|
onClick={handlePromoteCancel}
|
|
disabled={promoteToAdminMutation.isPending}
|
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handlePromoteConfirm}
|
|
disabled={promoteToAdminMutation.isPending}
|
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg font-medium min-h-[44px] disabled:opacity-50"
|
|
>
|
|
{promoteToAdminMutation.isPending ? 'Promoting...' : 'Promote'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-3">
|
|
<p className="text-slate-600">
|
|
Promote <strong>{selectedUser?.email}</strong> to an administrator role.
|
|
</p>
|
|
<div>
|
|
<label className="text-sm font-medium text-slate-600 block mb-1">Admin Role</label>
|
|
<select
|
|
value={promoteRole}
|
|
onChange={(e) => setPromoteRole(e.target.value as 'admin' | 'super_admin')}
|
|
className="w-full px-3 py-2 rounded-lg border border-slate-200 min-h-[44px]"
|
|
>
|
|
<option value="admin">Admin</option>
|
|
<option value="super_admin">Super Admin</option>
|
|
</select>
|
|
</div>
|
|
<p className="text-sm text-slate-500">
|
|
Admins can manage users, catalog data, and view audit logs.
|
|
Super Admins have additional permissions to manage other administrators.
|
|
</p>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Hard Delete Confirmation Modal */}
|
|
<Modal
|
|
isOpen={showHardDeleteModal}
|
|
onClose={() => !hardDeleteMutation.isPending && handleHardDeleteCancel()}
|
|
title="Permanently Delete User"
|
|
actions={
|
|
<>
|
|
<button
|
|
onClick={handleHardDeleteCancel}
|
|
disabled={hardDeleteMutation.isPending}
|
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium min-h-[44px]"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleHardDeleteConfirm}
|
|
disabled={hardDeleteMutation.isPending || hardDeleteConfirmText !== 'DELETE'}
|
|
className="px-4 py-2 bg-red-600 text-white rounded-lg font-medium min-h-[44px] disabled:opacity-50"
|
|
>
|
|
{hardDeleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-4">
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
|
<p className="text-sm font-semibold text-red-800">
|
|
Warning: This action cannot be undone!
|
|
</p>
|
|
<p className="text-sm text-red-700 mt-1">
|
|
All user data will be permanently deleted, including vehicles, fuel logs,
|
|
maintenance records, and documents.
|
|
</p>
|
|
</div>
|
|
|
|
<p className="text-slate-600">
|
|
Are you sure you want to permanently delete{' '}
|
|
<strong>{selectedUser?.email}</strong>?
|
|
</p>
|
|
|
|
<div>
|
|
<label className="text-sm font-medium text-slate-600 block mb-1">
|
|
Reason for deletion
|
|
</label>
|
|
<textarea
|
|
value={hardDeleteReason}
|
|
onChange={(e) => setHardDeleteReason(e.target.value)}
|
|
placeholder="GDPR request, user request, etc..."
|
|
className="w-full px-3 py-2 rounded-lg border border-slate-200 resize-none min-h-[60px]"
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-sm font-medium text-slate-600 block mb-1">
|
|
Type <strong>DELETE</strong> to confirm
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={hardDeleteConfirmText}
|
|
onChange={(e) => setHardDeleteConfirmText(e.target.value.toUpperCase())}
|
|
placeholder="Type DELETE"
|
|
className={`w-full px-3 py-2 rounded-lg border min-h-[44px] ${
|
|
hardDeleteConfirmText && hardDeleteConfirmText !== 'DELETE'
|
|
? 'border-red-500'
|
|
: 'border-slate-200'
|
|
}`}
|
|
style={{ fontSize: '16px' }}
|
|
/>
|
|
{hardDeleteConfirmText && hardDeleteConfirmText !== 'DELETE' && (
|
|
<p className="text-sm text-red-500 mt-1">Please type DELETE exactly</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</MobileContainer>
|
|
);
|
|
};
|