Files
smoothschedule/frontend/src/pages/platform/components/EditPlatformUserModal.tsx
poduck 3fef0d5749 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>
2025-11-28 20:54:07 -05:00

473 lines
19 KiB
TypeScript

/**
* 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;