382 lines
16 KiB
TypeScript
382 lines
16 KiB
TypeScript
import { useState, useRef } from 'react';
|
|
import { useAuthProviders } from '../../hooks/useSecurity';
|
|
import { Button } from '../../components/ui/Button';
|
|
import { Plus, Edit, Trash2, Globe } from 'lucide-react';
|
|
import toast from 'react-hot-toast';
|
|
import type { AuthProvider, CreateAuthProviderRequest, UpdateAuthProviderRequest } from '../../api/security';
|
|
|
|
interface ProviderFormData {
|
|
name: string;
|
|
type: 'google' | 'github' | 'oidc';
|
|
client_id: string;
|
|
client_secret: string;
|
|
issuer_url: string;
|
|
auth_url: string;
|
|
token_url: string;
|
|
user_info_url: string;
|
|
scopes: string;
|
|
display_name: string;
|
|
enabled: boolean;
|
|
}
|
|
|
|
interface HelpTooltipProps {
|
|
content: React.ReactNode;
|
|
position?: 'left' | 'right';
|
|
}
|
|
|
|
const HelpTooltip = ({ content, position = 'left' }: HelpTooltipProps) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const handleMouseEnter = () => {
|
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
setIsOpen(true);
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
timeoutRef.current = setTimeout(() => {
|
|
setIsOpen(false);
|
|
}, 300);
|
|
};
|
|
|
|
return (
|
|
<div className="relative inline-block" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
|
<button
|
|
type="button"
|
|
className="w-4 h-4 rounded-full bg-gray-700 text-gray-400 hover:bg-gray-600 flex items-center justify-center text-xs cursor-help"
|
|
onClick={(e) => e.preventDefault()}
|
|
>
|
|
?
|
|
</button>
|
|
{isOpen && (
|
|
<div
|
|
className={`absolute bottom-6 ${position === 'left' ? 'left-0' : 'right-0'} bg-gray-800 text-white text-xs rounded-lg px-3 py-2 w-72 z-10 shadow-lg border border-gray-700`}
|
|
>
|
|
{content}
|
|
<div className={`absolute top-full ${position === 'left' ? 'left-1' : 'right-1'} border-4 border-transparent border-t-gray-800`}></div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default function Providers() {
|
|
const { providers, createProvider, updateProvider, deleteProvider, isLoading } = useAuthProviders();
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [editingProvider, setEditingProvider] = useState<AuthProvider | null>(null);
|
|
const [formData, setFormData] = useState<ProviderFormData>({
|
|
name: '',
|
|
type: 'oidc',
|
|
client_id: '',
|
|
client_secret: '',
|
|
issuer_url: '',
|
|
auth_url: '',
|
|
token_url: '',
|
|
user_info_url: '',
|
|
scopes: 'openid,profile,email',
|
|
display_name: '',
|
|
enabled: true,
|
|
});
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
if (editingProvider) {
|
|
const updateData: UpdateAuthProviderRequest = {
|
|
name: formData.name,
|
|
client_id: formData.client_id,
|
|
issuer_url: formData.issuer_url,
|
|
auth_url: formData.auth_url,
|
|
token_url: formData.token_url,
|
|
user_info_url: formData.user_info_url,
|
|
scopes: formData.scopes,
|
|
display_name: formData.display_name,
|
|
enabled: formData.enabled,
|
|
};
|
|
if (formData.client_secret) {
|
|
updateData.client_secret = formData.client_secret;
|
|
}
|
|
await updateProvider({ uuid: editingProvider.uuid, data: updateData });
|
|
toast.success('Provider updated successfully');
|
|
} else {
|
|
const createData: CreateAuthProviderRequest = {
|
|
name: formData.name,
|
|
type: formData.type,
|
|
client_id: formData.client_id,
|
|
client_secret: formData.client_secret,
|
|
issuer_url: formData.issuer_url,
|
|
auth_url: formData.auth_url,
|
|
token_url: formData.token_url,
|
|
user_info_url: formData.user_info_url,
|
|
scopes: formData.scopes,
|
|
display_name: formData.display_name,
|
|
};
|
|
await createProvider(createData);
|
|
toast.success('Provider created successfully');
|
|
}
|
|
setIsModalOpen(false);
|
|
resetForm();
|
|
} catch (error: unknown) {
|
|
const err = error as { response?: { data?: { error?: string } } };
|
|
toast.error(err.response?.data?.error || 'Failed to save provider');
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (uuid: string) => {
|
|
if (confirm('Are you sure you want to delete this provider?')) {
|
|
try {
|
|
await deleteProvider(uuid);
|
|
toast.success('Provider deleted successfully');
|
|
} catch (error: unknown) {
|
|
const err = error as { response?: { data?: { error?: string } } };
|
|
toast.error(err.response?.data?.error || 'Failed to delete provider');
|
|
}
|
|
}
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setFormData({
|
|
name: '',
|
|
type: 'oidc',
|
|
client_id: '',
|
|
client_secret: '',
|
|
issuer_url: '',
|
|
auth_url: '',
|
|
token_url: '',
|
|
user_info_url: '',
|
|
scopes: 'openid,profile,email',
|
|
display_name: '',
|
|
enabled: true,
|
|
});
|
|
setEditingProvider(null);
|
|
};
|
|
|
|
const openEditModal = (provider: AuthProvider) => {
|
|
setEditingProvider(provider);
|
|
setFormData({
|
|
name: provider.name,
|
|
type: provider.type,
|
|
client_id: provider.client_id,
|
|
client_secret: '', // Don't populate secret
|
|
issuer_url: provider.issuer_url || '',
|
|
auth_url: provider.auth_url || '',
|
|
token_url: provider.token_url || '',
|
|
user_info_url: provider.user_info_url || '',
|
|
scopes: provider.scopes || 'openid,profile,email',
|
|
display_name: provider.display_name || '',
|
|
enabled: provider.enabled,
|
|
});
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
if (isLoading) return <div>Loading...</div>;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex justify-between items-center">
|
|
<h2 className="text-xl font-semibold text-white">Identity Providers</h2>
|
|
<Button onClick={() => { resetForm(); setIsModalOpen(true); }}>
|
|
<Plus size={16} className="mr-2" />
|
|
Add Provider
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{providers.map((provider) => (
|
|
<div key={provider.uuid} className="bg-dark-card rounded-lg border border-gray-800 p-6 space-y-4">
|
|
<div className="flex justify-between items-start">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-lg bg-purple-900/30 flex items-center justify-center text-purple-400">
|
|
<Globe size={20} />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-medium text-white">{provider.name}</h3>
|
|
<p className="text-xs text-gray-500 uppercase">{provider.type}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => openEditModal(provider)}
|
|
className="p-1.5 rounded-md hover:bg-gray-700 text-gray-400 hover:text-white transition-colors"
|
|
>
|
|
<Edit size={16} />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(provider.uuid)}
|
|
className="p-1.5 rounded-md hover:bg-red-900/30 text-gray-400 hover:text-red-400 transition-colors"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 text-sm text-gray-400">
|
|
<div className="flex justify-between">
|
|
<span>Client ID:</span>
|
|
<span className="font-mono text-gray-300">{provider.client_id}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>Status:</span>
|
|
{provider.enabled ? (
|
|
<span className="text-green-400">Active</span>
|
|
) : (
|
|
<span className="text-red-400">Disabled</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{providers.length === 0 && (
|
|
<div className="col-span-full text-center py-12 text-gray-500 bg-dark-card rounded-lg border border-gray-800 border-dashed">
|
|
No identity providers configured. Add one to enable external authentication.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Provider Modal */}
|
|
{isModalOpen && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
|
<div className="bg-dark-card rounded-lg border border-gray-800 max-w-3xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
|
<h3 className="text-lg font-bold text-white mb-4">
|
|
{editingProvider ? 'Edit Provider' : 'Add Provider'}
|
|
</h3>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-400 mb-1">Name</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={formData.name}
|
|
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
|
placeholder="Google"
|
|
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-400 mb-1">Type</label>
|
|
<select
|
|
value={formData.type}
|
|
onChange={e => setFormData({ ...formData, type: e.target.value as 'google' | 'github' | 'oidc' })}
|
|
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
|
>
|
|
<option value="oidc">Generic OIDC</option>
|
|
<option value="google">Google</option>
|
|
<option value="github">GitHub</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<label className="block text-sm font-medium text-gray-400">Client ID</label>
|
|
<HelpTooltip
|
|
position="left"
|
|
content={
|
|
<>
|
|
<div className="mb-2">The public identifier for your OAuth application.</div>
|
|
<div className="space-y-1">
|
|
<div><strong>Google:</strong> <a href="https://console.cloud.google.com/apis/credentials" target="_blank" rel="noopener" className="text-blue-300 hover:text-blue-200 underline">Google Cloud Console</a></div>
|
|
<div><strong>GitHub:</strong> <a href="https://github.com/settings/developers" target="_blank" rel="noopener" className="text-blue-300 hover:text-blue-200 underline">Developer Settings</a></div>
|
|
</div>
|
|
</>
|
|
}
|
|
/>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={formData.client_id}
|
|
onChange={e => setFormData({ ...formData, client_id: e.target.value })}
|
|
placeholder="e.g., 123456789.apps.googleusercontent.com"
|
|
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<label className="block text-sm font-medium text-gray-400">
|
|
{editingProvider ? 'Client Secret (leave blank to keep)' : 'Client Secret'}
|
|
</label>
|
|
<HelpTooltip
|
|
position="right"
|
|
content={
|
|
<>
|
|
<div className="mb-2">The private key for your OAuth application. Keep this secret and secure!</div>
|
|
<div className="space-y-1">
|
|
<div><strong>Google:</strong> <a href="https://console.cloud.google.com/apis/credentials" target="_blank" rel="noopener" className="text-blue-300 hover:text-blue-200 underline">Google Cloud Console</a></div>
|
|
<div><strong>GitHub:</strong> <a href="https://github.com/settings/developers" target="_blank" rel="noopener" className="text-blue-300 hover:text-blue-200 underline">Developer Settings</a></div>
|
|
</div>
|
|
</>
|
|
}
|
|
/>
|
|
</div>
|
|
<input
|
|
type="password"
|
|
required={!editingProvider}
|
|
value={formData.client_secret}
|
|
onChange={e => setFormData({ ...formData, client_secret: e.target.value })}
|
|
placeholder="Enter your client secret"
|
|
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{formData.type === 'oidc' && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-400 mb-1">Issuer URL (Discovery)</label>
|
|
<input
|
|
type="url"
|
|
value={formData.issuer_url}
|
|
onChange={e => setFormData({ ...formData, issuer_url: e.target.value })}
|
|
placeholder="https://accounts.google.com"
|
|
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-400 mb-1">Scopes</label>
|
|
<input
|
|
type="text"
|
|
value={formData.scopes}
|
|
onChange={e => setFormData({ ...formData, scopes: e.target.value })}
|
|
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-400 mb-1">Display Name</label>
|
|
<input
|
|
type="text"
|
|
value={formData.display_name}
|
|
onChange={e => setFormData({ ...formData, display_name: e.target.value })}
|
|
placeholder="Sign in with Google"
|
|
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white focus:ring-2 focus:ring-blue-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id="enabled"
|
|
checked={formData.enabled}
|
|
onChange={e => setFormData({ ...formData, enabled: e.target.checked })}
|
|
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
|
/>
|
|
<label htmlFor="enabled" className="text-sm text-gray-400">Enabled</label>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 mt-6">
|
|
<Button variant="ghost" onClick={() => setIsModalOpen(false)}>Cancel</Button>
|
|
<Button type="submit">{editingProvider ? 'Save Changes' : 'Create Provider'}</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|