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>
473 lines
19 KiB
TypeScript
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;
|