Notification updates

This commit is contained in:
Eric Gullickson
2025-12-21 19:56:52 -06:00
parent 144f1d5bb0
commit 719c80ecd8
80 changed files with 7552 additions and 678 deletions

View File

@@ -1,61 +1,721 @@
/**
* @ai-summary Mobile admin screen for user management
* @ai-context Manage admin users with mobile-optimized interface
* @ai-context List users, search, filter, change tiers, deactivate/reactivate
*/
import React from 'react';
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,
} from '../hooks/useUsers';
import {
ManagedUser,
SubscriptionTier,
ListUsersParams,
} from '../types/admin.types';
// 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 rounded-xl p-6 max-w-sm w-full shadow-xl">
<h3 className="text-lg font-semibold text-slate-800 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>
);
export const AdminUsersMobileScreen: React.FC = () => {
const { isAdmin, loading } = useAdminAccess();
const { isAdmin, loading: adminLoading } = useAdminAccess();
if (loading) {
// 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);
// Mutations
const updateTierMutation = useUpdateUserTier();
const deactivateMutation = useDeactivateUser();
const reactivateMutation = useReactivateUser();
const updateProfileMutation = useUpdateUserProfile();
const promoteToAdminMutation = usePromoteToAdmin();
// 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');
// 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 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 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">User Management</h1>
<p className="text-slate-500 mt-2">Manage admin users and permissions</p>
<p className="text-slate-500 mt-2">
{total} user{total !== 1 ? 's' : ''}
</p>
</div>
<GlassCard padding="md">
<div>
<h2 className="text-lg font-semibold text-slate-800 mb-4">Admin Users</h2>
<p className="text-sm text-slate-600 mb-4">
Admin user management interface coming soon.
</p>
<div className="space-y-2 text-sm text-slate-600">
<p className="font-semibold">Features:</p>
<ul className="list-disc pl-5 space-y-1">
<li>List all admin users</li>
<li>Add new admin users</li>
<li>Revoke admin access</li>
<li>Reinstate revoked admins</li>
<li>View audit logs</li>
</ul>
{/* 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 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 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 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 truncate">{user.email}</p>
{user.displayName && (
<p className="text-sm text-slate-500 truncate">{user.displayName}</p>
)}
<div className="flex flex-wrap gap-2 mt-2">
<TierBadge tier={user.subscriptionTier} />
<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>
</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>
)}
</div>
</div>
</GlassCard>
</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>
</MobileContainer>
);
};