feat: Add comprehensive plugin documentation and advanced template system
Added complete plugin documentation with visual mockups and expanded template
variable system with CONTEXT, DATE helpers, and default values.
Backend Changes:
- Extended template_parser.py to support all new template types
- Added PROMPT with default values: {{PROMPT:var|desc|default}}
- Added CONTEXT variables: {{CONTEXT:business_name}}, {{CONTEXT:owner_email}}
- Added DATE helpers: {{DATE:today}}, {{DATE:+7d}}, {{DATE:monday}}
- Implemented date expression evaluation for relative dates
- Updated compile_template to handle all template types
- Added context parameter for business data auto-fill
Frontend Changes:
- Created comprehensive HelpPluginDocs.tsx with Stripe-style API docs
- Added visual mockup of plugin configuration form
- Documented all template types with examples and benefits
- Added Command Reference section with allowed/blocked Python commands
- Documented all HTTP methods (GET, POST, PUT, PATCH, DELETE)
- Added URL whitelisting requirements and approval process
- Created Platform Staff management page with edit modal
- Added can_approve_plugins and can_whitelist_urls permissions
Platform Staff Features:
- List all platform_manager and platform_support users
- Edit user details with role-based permissions
- Superusers can edit anyone
- Platform managers can only edit platform_support users
- Permission cascade: users can only grant permissions they have
- Real-time updates via React Query cache invalidation
Documentation Highlights:
- 4 template types: PROMPT, CONTEXT, DATE, and automatic validation
- Visual form mockup showing exactly what users see
- All allowed control flow (if/elif/else, for, while, try/except, etc.)
- All allowed built-in functions (len, range, min, max, etc.)
- All blocked operations (import, exec, eval, class/function defs)
- Complete HTTP API reference with examples
- URL whitelisting process: contact pluginaccess@smoothschedule.com
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,7 @@ import PlatformDashboard from './pages/platform/PlatformDashboard';
|
||||
import PlatformBusinesses from './pages/platform/PlatformBusinesses';
|
||||
import PlatformSupportPage from './pages/platform/PlatformSupport';
|
||||
import PlatformUsers from './pages/platform/PlatformUsers';
|
||||
import PlatformStaff from './pages/platform/PlatformStaff';
|
||||
import PlatformSettings from './pages/platform/PlatformSettings';
|
||||
import ProfileSettings from './pages/ProfileSettings';
|
||||
import VerifyEmail from './pages/VerifyEmail';
|
||||
@@ -60,6 +61,7 @@ import Tickets from './pages/Tickets'; // Import Tickets page
|
||||
import HelpGuide from './pages/HelpGuide'; // Import Platform Guide page
|
||||
import HelpTicketing from './pages/HelpTicketing'; // Import Help page for ticketing
|
||||
import HelpApiDocs from './pages/HelpApiDocs'; // Import API documentation page
|
||||
import HelpPluginDocs from './pages/HelpPluginDocs'; // Import Plugin documentation page
|
||||
import PlatformSupport from './pages/PlatformSupport'; // Import Platform Support page (for businesses to contact SmoothSchedule)
|
||||
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
|
||||
|
||||
@@ -324,12 +326,14 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/platform/dashboard" element={<PlatformDashboard />} />
|
||||
<Route path="/platform/businesses" element={<PlatformBusinesses onMasquerade={handleMasquerade} />} />
|
||||
<Route path="/platform/users" element={<PlatformUsers onMasquerade={handleMasquerade} />} />
|
||||
<Route path="/platform/staff" element={<PlatformStaff />} />
|
||||
</>
|
||||
)}
|
||||
<Route path="/platform/support" element={<PlatformSupportPage />} />
|
||||
<Route path="/help/guide" element={<HelpGuide />} />
|
||||
<Route path="/help/ticketing" element={<HelpTicketing />} />
|
||||
<Route path="/help/api" element={<HelpApiDocs />} />
|
||||
<Route path="/help/plugins" element={<HelpPluginDocs />} />
|
||||
{user.role === 'superuser' && (
|
||||
<Route path="/platform/settings" element={<PlatformSettings />} />
|
||||
)}
|
||||
@@ -509,6 +513,7 @@ const AppContent: React.FC = () => {
|
||||
<Route path="/help/guide" element={<HelpGuide />} />
|
||||
<Route path="/help/ticketing" element={<HelpTicketing />} />
|
||||
<Route path="/help/api" element={<HelpApiDocs />} />
|
||||
<Route path="/help/plugins" element={<HelpPluginDocs />} />
|
||||
<Route path="/support" element={<PlatformSupport />} />
|
||||
<Route
|
||||
path="/customers"
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
ChevronDown,
|
||||
BookOpen,
|
||||
FileQuestion,
|
||||
LifeBuoy
|
||||
LifeBuoy,
|
||||
Zap
|
||||
} from 'lucide-react';
|
||||
import { Business, User } from '../types';
|
||||
import { useLogout } from '../hooks/useAuth';
|
||||
@@ -215,14 +216,24 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
<span>{t('nav.ticketingHelp', 'Ticketing System')}</span>
|
||||
</Link>
|
||||
{role === 'owner' && (
|
||||
<Link
|
||||
to="/help/api"
|
||||
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/help/api' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||
title={t('nav.apiDocs', 'API Documentation')}
|
||||
>
|
||||
<Code size={16} className="shrink-0" />
|
||||
<span>{t('nav.apiDocs', 'API Docs')}</span>
|
||||
</Link>
|
||||
<>
|
||||
<Link
|
||||
to="/help/api"
|
||||
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/help/api' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||
title={t('nav.apiDocs', 'API Documentation')}
|
||||
>
|
||||
<Code size={16} className="shrink-0" />
|
||||
<span>{t('nav.apiDocs', 'API Docs')}</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/help/plugins"
|
||||
className={`flex items-center gap-3 py-2 text-sm font-medium rounded-lg transition-colors px-3 ${location.pathname === '/help/plugins' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white hover:bg-white/5'}`}
|
||||
title={t('nav.pluginDocs', 'Automation Plugins')}
|
||||
>
|
||||
<Zap size={16} className="shrink-0" />
|
||||
<span>{t('nav.pluginDocs', 'Plugin Docs')}</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<div className="pt-2 mt-2 border-t border-white/10">
|
||||
<Link
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
"platformGuide": "Platform Guide",
|
||||
"ticketingHelp": "Ticketing System",
|
||||
"apiDocs": "API Docs",
|
||||
"pluginDocs": "Plugin Docs",
|
||||
"contactSupport": "Contact Support"
|
||||
},
|
||||
"help": {
|
||||
|
||||
1688
frontend/src/pages/HelpPluginDocs.tsx
Normal file
1688
frontend/src/pages/HelpPluginDocs.tsx
Normal file
File diff suppressed because it is too large
Load Diff
348
frontend/src/pages/platform/PlatformStaff.tsx
Normal file
348
frontend/src/pages/platform/PlatformStaff.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Platform Staff Management Page
|
||||
* Allows superusers to manage platform staff (platform_manager, platform_support)
|
||||
* with full editing capabilities including permissions
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Users,
|
||||
Search,
|
||||
Plus,
|
||||
Pencil,
|
||||
Shield,
|
||||
Mail,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { usePlatformUsers } from '../../hooks/usePlatform';
|
||||
import { useCurrentUser } from '../../hooks/useAuth';
|
||||
import EditPlatformUserModal from './components/EditPlatformUserModal';
|
||||
|
||||
interface PlatformUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
full_name?: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
permissions: {
|
||||
can_approve_plugins?: boolean;
|
||||
can_whitelist_urls?: boolean;
|
||||
[key: string]: any;
|
||||
};
|
||||
created_at: string;
|
||||
last_login?: string;
|
||||
}
|
||||
|
||||
const PlatformStaff: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: currentUser } = useCurrentUser();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedUser, setSelectedUser] = useState<PlatformUser | null>(null);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
|
||||
const { data: allUsers, isLoading, error } = usePlatformUsers();
|
||||
|
||||
// Filter to only show platform staff (not superusers, not business users)
|
||||
const platformStaff = (allUsers || []).filter(
|
||||
(u: any) => u.role === 'platform_manager' || u.role === 'platform_support'
|
||||
);
|
||||
|
||||
const filteredStaff = platformStaff.filter((u: any) => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
(u.full_name || u.username || '').toLowerCase().includes(searchLower) ||
|
||||
u.email.toLowerCase().includes(searchLower) ||
|
||||
u.username.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
const canEditUser = (user: PlatformUser) => {
|
||||
if (!currentUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentRole = currentUser.role.toLowerCase();
|
||||
const targetRole = user.role.toLowerCase();
|
||||
|
||||
// Superusers can edit anyone
|
||||
if (currentRole === 'superuser') {
|
||||
return true;
|
||||
}
|
||||
// Platform managers can only edit platform_support users, not other managers or superusers
|
||||
if (currentRole === 'platform_manager') {
|
||||
return targetRole === 'platform_support';
|
||||
}
|
||||
// All others cannot edit
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleEdit = (user: any) => {
|
||||
if (!canEditUser(user)) {
|
||||
return; // Silently ignore if user cannot be edited
|
||||
}
|
||||
setSelectedUser(user);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const getRoleBadge = (role: string) => {
|
||||
if (role === 'platform_manager') {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300">
|
||||
<Shield className="w-3 h-3 mr-1" />
|
||||
Platform Manager
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (role === 'platform_support') {
|
||||
return (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
<Mail className="w-3 h-3 mr-1" />
|
||||
Platform Support
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return 'Never';
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
|
||||
<XCircle className="w-5 h-5" />
|
||||
<span>Failed to load platform staff</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Users className="w-7 h-7" />
|
||||
Platform Staff
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">
|
||||
Manage platform managers and support staff
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
|
||||
onClick={() => {
|
||||
// TODO: Implement create new staff member
|
||||
alert('Create new staff member - coming soon');
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Staff Member
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-6">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search staff by name, email, or username..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Total Staff</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
|
||||
{platformStaff.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Platform Managers</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
|
||||
{platformStaff.filter((u: any) => u.role === 'platform_manager').length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">Support Staff</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
|
||||
{platformStaff.filter((u: any) => u.role === 'platform_support').length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Staff List */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Staff Member
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Role
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Permissions
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Last Login
|
||||
</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{filteredStaff.map((user: any) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
{/* Staff Member */}
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white font-semibold">
|
||||
{(user.full_name || user.username).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
{user.full_name || user.username}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Role */}
|
||||
<td className="px-6 py-4">{getRoleBadge(user.role)}</td>
|
||||
|
||||
{/* Permissions */}
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.permissions?.can_approve_plugins && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
Plugin Approver
|
||||
</span>
|
||||
)}
|
||||
{user.permissions?.can_whitelist_urls && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
URL Whitelister
|
||||
</span>
|
||||
)}
|
||||
{!user.permissions?.can_approve_plugins && !user.permissions?.can_whitelist_urls && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
No special permissions
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="px-6 py-4">
|
||||
{user.is_active ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Last Login */}
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{formatDate(user.last_login)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button
|
||||
onClick={() => handleEdit(user)}
|
||||
disabled={!canEditUser(user)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredStaff.length === 0 && (
|
||||
<div className="p-12 text-center">
|
||||
<Users className="w-12 h-12 mx-auto text-gray-400 dark:text-gray-600 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
No staff members found
|
||||
</h3>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{searchTerm
|
||||
? 'Try adjusting your search criteria'
|
||||
: 'Add your first platform staff member to get started'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{selectedUser && (
|
||||
<EditPlatformUserModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={() => {
|
||||
setIsEditModalOpen(false);
|
||||
setSelectedUser(null);
|
||||
}}
|
||||
user={selectedUser}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformStaff;
|
||||
472
frontend/src/pages/platform/components/EditPlatformUserModal.tsx
Normal file
472
frontend/src/pages/platform/components/EditPlatformUserModal.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* Edit Platform User Modal
|
||||
* Allows superusers to edit all aspects of platform staff including:
|
||||
* - Basic info (name, email, username)
|
||||
* - Password reset
|
||||
* - Role assignment
|
||||
* - Permissions (can_approve_plugins, etc.)
|
||||
* - Account status (active/inactive)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
X,
|
||||
User,
|
||||
Mail,
|
||||
Lock,
|
||||
Shield,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Save,
|
||||
Eye,
|
||||
EyeOff,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '../../../api/client';
|
||||
import { useCurrentUser } from '../../../hooks/useAuth';
|
||||
|
||||
interface EditPlatformUserModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
permissions: {
|
||||
can_approve_plugins?: boolean;
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
user,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: currentUser } = useCurrentUser();
|
||||
|
||||
// Check if current user can edit this user
|
||||
const currentRole = currentUser?.role?.toLowerCase();
|
||||
const targetRole = user.role?.toLowerCase();
|
||||
|
||||
const canEditRole = currentRole === 'superuser' ||
|
||||
(currentRole === 'platform_manager' && targetRole === 'platform_support');
|
||||
|
||||
// Get available permissions for current user
|
||||
// Superusers always have all permissions, others check the permissions field
|
||||
const availablePermissions = {
|
||||
can_approve_plugins: currentRole === 'superuser' || !!currentUser?.permissions?.can_approve_plugins,
|
||||
can_whitelist_urls: currentRole === 'superuser' || !!currentUser?.permissions?.can_whitelist_urls,
|
||||
};
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
role: user.role,
|
||||
is_active: user.is_active,
|
||||
});
|
||||
|
||||
const [permissions, setPermissions] = useState({
|
||||
can_approve_plugins: user.permissions?.can_approve_plugins || false,
|
||||
can_whitelist_urls: user.permissions?.can_whitelist_urls || false,
|
||||
});
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
const response = await apiClient.patch(`/api/platform/users/${user.id}/`, data);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['platform', 'users'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
// Reset form when user changes
|
||||
useEffect(() => {
|
||||
setFormData({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
role: user.role,
|
||||
is_active: user.is_active,
|
||||
});
|
||||
setPermissions({
|
||||
can_approve_plugins: user.permissions?.can_approve_plugins || false,
|
||||
can_whitelist_urls: user.permissions?.can_whitelist_urls || false,
|
||||
});
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
setPasswordError('');
|
||||
}, [user]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate password if provided
|
||||
if (password) {
|
||||
if (password !== confirmPassword) {
|
||||
setPasswordError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setPasswordError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: any = {
|
||||
...formData,
|
||||
permissions: permissions,
|
||||
};
|
||||
|
||||
// Only include password if changed
|
||||
if (password) {
|
||||
updateData.password = password;
|
||||
}
|
||||
|
||||
updateMutation.mutate(updateData);
|
||||
};
|
||||
|
||||
const handlePermissionToggle = (permission: string) => {
|
||||
setPermissions((prev) => ({
|
||||
...prev,
|
||||
[permission]: !prev[permission as keyof typeof prev],
|
||||
}));
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
{/* Background overlay */}
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-900/75 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal panel */}
|
||||
<div className="relative inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full z-50">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center">
|
||||
<Shield className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Edit Platform User</h3>
|
||||
<p className="text-sm text-indigo-100">
|
||||
{user.username} ({user.email})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white hover:text-indigo-100 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
Basic Information
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 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 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Details */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Mail className="w-4 h-4" />
|
||||
Account Details
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, username: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email Address
|
||||
</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 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
Role & Access
|
||||
</h4>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Platform Role
|
||||
</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
||||
disabled={!canEditRole}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="platform_manager">Platform Manager</option>
|
||||
<option value="platform_support">Platform Support</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{canEditRole
|
||||
? 'Platform Managers have full administrative access. Support staff have limited access.'
|
||||
: 'You do not have permission to change this user\'s role.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Special Permissions
|
||||
</h4>
|
||||
<div className="space-y-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
{availablePermissions.can_approve_plugins && (
|
||||
<label className="flex items-start gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permissions.can_approve_plugins}
|
||||
onChange={() => handlePermissionToggle('can_approve_plugins')}
|
||||
className="mt-0.5 w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
|
||||
Can Approve Plugins
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Allow this user to review and approve community plugins for the marketplace
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
{availablePermissions.can_whitelist_urls && (
|
||||
<label className="flex items-start gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permissions.can_whitelist_urls}
|
||||
onChange={() => handlePermissionToggle('can_whitelist_urls')}
|
||||
className="mt-0.5 w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
|
||||
Can Whitelist URLs
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Allow this user to whitelist external URLs for plugin API calls (per-user and platform-wide)
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
{!availablePermissions.can_approve_plugins && !availablePermissions.can_whitelist_urls && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
You don't have any special permissions to grant.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Reset */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Lock className="w-4 h-4" />
|
||||
Reset Password (Optional)
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
New Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setPasswordError('');
|
||||
}}
|
||||
placeholder="Leave blank to keep current password"
|
||||
className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{password && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => {
|
||||
setConfirmPassword(e.target.value);
|
||||
setPasswordError('');
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{passwordError && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{passwordError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Status */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Account Status
|
||||
</h4>
|
||||
<label className="flex items-center gap-3 cursor-pointer group bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, is_active: e.target.checked })
|
||||
}
|
||||
className="w-4 h-4 text-green-600 border-gray-300 rounded focus:ring-green-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{formData.is_active ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{formData.is_active ? 'Account Active' : 'Account Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{formData.is_active
|
||||
? 'User can log in and access the platform'
|
||||
: 'User cannot log in or access the platform'}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{updateMutation.isError && (
|
||||
<div className="mt-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<span>Failed to update user. Please try again.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-6 flex items-center justify-end gap-3 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditPlatformUserModal;
|
||||
Reference in New Issue
Block a user