- Add email template presets for Browse Templates tab (12 templates) - Add bulk selection and deletion for My Templates tab - Add communication credits system with Twilio integration - Add payment views for credit purchases and auto-reload - Add SMS reminder and masked calling plan permissions - Fix appointment status mapping (frontend/backend mismatch) - Clear masquerade stack on login/logout for session hygiene - Update platform settings with credit configuration - Add new migrations for Twilio and Stripe payment fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
428 lines
21 KiB
TypeScript
428 lines
21 KiB
TypeScript
/**
|
|
* Authentication Settings Page
|
|
*
|
|
* Configure OAuth providers, social login, and custom credentials.
|
|
*/
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useOutletContext } from 'react-router-dom';
|
|
import { Lock, Users, Key, Save, Check, AlertCircle, Eye, EyeOff } from 'lucide-react';
|
|
import { Business, User } from '../../types';
|
|
import { useBusinessOAuthSettings, useUpdateBusinessOAuthSettings } from '../../hooks/useBusinessOAuth';
|
|
import { useBusinessOAuthCredentials, useUpdateBusinessOAuthCredentials } from '../../hooks/useBusinessOAuthCredentials';
|
|
|
|
// Provider display names and icons
|
|
const providerInfo: Record<string, { name: string; icon: string }> = {
|
|
google: { name: 'Google', icon: '🔍' },
|
|
apple: { name: 'Apple', icon: '🍎' },
|
|
facebook: { name: 'Facebook', icon: '📘' },
|
|
linkedin: { name: 'LinkedIn', icon: '💼' },
|
|
microsoft: { name: 'Microsoft', icon: '🪟' },
|
|
twitter: { name: 'X (Twitter)', icon: '🐦' },
|
|
twitch: { name: 'Twitch', icon: '🎮' },
|
|
};
|
|
|
|
const AuthenticationSettings: React.FC = () => {
|
|
const { t } = useTranslation();
|
|
const { business, user } = useOutletContext<{
|
|
business: Business;
|
|
user: User;
|
|
}>();
|
|
|
|
// OAuth Settings hooks
|
|
const { data: oauthData, isLoading: oauthLoading } = useBusinessOAuthSettings();
|
|
const updateOAuthMutation = useUpdateBusinessOAuthSettings();
|
|
const [oauthSettings, setOAuthSettings] = useState({
|
|
enabledProviders: [] as string[],
|
|
allowRegistration: false,
|
|
autoLinkByEmail: true,
|
|
useCustomCredentials: false,
|
|
});
|
|
|
|
// OAuth Credentials hooks
|
|
const { data: oauthCredentials, isLoading: credentialsLoading } = useBusinessOAuthCredentials();
|
|
const updateCredentialsMutation = useUpdateBusinessOAuthCredentials();
|
|
const [useCustomCredentials, setUseCustomCredentials] = useState(false);
|
|
const [credentials, setCredentials] = useState<any>({
|
|
google: { client_id: '', client_secret: '' },
|
|
apple: { client_id: '', client_secret: '', team_id: '', key_id: '' },
|
|
facebook: { client_id: '', client_secret: '' },
|
|
linkedin: { client_id: '', client_secret: '' },
|
|
microsoft: { client_id: '', client_secret: '', tenant_id: '' },
|
|
twitter: { client_id: '', client_secret: '' },
|
|
twitch: { client_id: '', client_secret: '' },
|
|
});
|
|
const [showSecrets, setShowSecrets] = useState<{ [key: string]: boolean }>({});
|
|
const [showToast, setShowToast] = useState(false);
|
|
|
|
const isOwner = user.role === 'owner';
|
|
|
|
// Update OAuth settings when data loads
|
|
useEffect(() => {
|
|
if (oauthData?.settings) {
|
|
setOAuthSettings(oauthData.settings);
|
|
}
|
|
}, [oauthData]);
|
|
|
|
// Update OAuth credentials when data loads
|
|
useEffect(() => {
|
|
if (oauthCredentials) {
|
|
setUseCustomCredentials(oauthCredentials.useCustomCredentials || false);
|
|
const creds = oauthCredentials.credentials || {};
|
|
setCredentials({
|
|
google: creds.google || { client_id: '', client_secret: '' },
|
|
apple: creds.apple || { client_id: '', client_secret: '', team_id: '', key_id: '' },
|
|
facebook: creds.facebook || { client_id: '', client_secret: '' },
|
|
linkedin: creds.linkedin || { client_id: '', client_secret: '' },
|
|
microsoft: creds.microsoft || { client_id: '', client_secret: '', tenant_id: '' },
|
|
twitter: creds.twitter || { client_id: '', client_secret: '' },
|
|
twitch: creds.twitch || { client_id: '', client_secret: '' },
|
|
});
|
|
}
|
|
}, [oauthCredentials]);
|
|
|
|
// Auto-hide toast
|
|
useEffect(() => {
|
|
if (showToast) {
|
|
const timer = setTimeout(() => setShowToast(false), 3000);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [showToast]);
|
|
|
|
const handleOAuthSave = () => {
|
|
updateOAuthMutation.mutate(oauthSettings, {
|
|
onSuccess: () => {
|
|
setShowToast(true);
|
|
},
|
|
});
|
|
};
|
|
|
|
const toggleProvider = (provider: string) => {
|
|
setOAuthSettings((prev) => {
|
|
const isEnabled = prev.enabledProviders.includes(provider);
|
|
return {
|
|
...prev,
|
|
enabledProviders: isEnabled
|
|
? prev.enabledProviders.filter((p) => p !== provider)
|
|
: [...prev.enabledProviders, provider],
|
|
};
|
|
});
|
|
};
|
|
|
|
const handleCredentialsSave = () => {
|
|
const updateData: any = {
|
|
use_custom_credentials: useCustomCredentials,
|
|
};
|
|
|
|
if (useCustomCredentials) {
|
|
Object.entries(credentials).forEach(([provider, creds]: [string, any]) => {
|
|
if (creds.client_id || creds.client_secret) {
|
|
updateData[provider] = creds;
|
|
}
|
|
});
|
|
}
|
|
|
|
updateCredentialsMutation.mutate(updateData, {
|
|
onSuccess: () => {
|
|
setShowToast(true);
|
|
},
|
|
});
|
|
};
|
|
|
|
const updateCredential = (provider: string, field: string, value: string) => {
|
|
setCredentials((prev: any) => ({
|
|
...prev,
|
|
[provider]: {
|
|
...prev[provider],
|
|
[field]: value,
|
|
},
|
|
}));
|
|
};
|
|
|
|
const toggleShowSecret = (key: string) => {
|
|
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
};
|
|
|
|
if (!isOwner) {
|
|
return (
|
|
<div className="text-center py-12">
|
|
<p className="text-gray-500 dark:text-gray-400">
|
|
Only the business owner can access these settings.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
|
<Lock className="text-purple-500" />
|
|
{t('settings.authentication.title', 'Authentication')}
|
|
</h2>
|
|
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
|
Configure social login and OAuth providers for customer sign-in.
|
|
</p>
|
|
</div>
|
|
|
|
{/* OAuth & Social Login */}
|
|
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
|
<Users size={20} className="text-indigo-500" /> Social Login
|
|
</h3>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">Choose which providers customers can use to sign in</p>
|
|
</div>
|
|
<button
|
|
onClick={handleOAuthSave}
|
|
disabled={oauthLoading || updateOAuthMutation.isPending}
|
|
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<Save size={16} />
|
|
{updateOAuthMutation.isPending ? 'Saving...' : 'Save'}
|
|
</button>
|
|
</div>
|
|
|
|
{oauthLoading ? (
|
|
<div className="flex items-center justify-center py-6">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
|
</div>
|
|
) : oauthData?.availableProviders && oauthData.availableProviders.length > 0 ? (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
|
{oauthData.availableProviders.map((provider: any) => {
|
|
const isEnabled = oauthSettings.enabledProviders.includes(provider.id);
|
|
const info = providerInfo[provider.id] || { name: provider.name, icon: '🔐' };
|
|
return (
|
|
<button
|
|
key={provider.id}
|
|
type="button"
|
|
onClick={() => toggleProvider(provider.id)}
|
|
className={`relative p-3 rounded-lg border-2 transition-all text-left ${
|
|
isEnabled
|
|
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
|
|
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
|
}`}
|
|
>
|
|
{isEnabled && (
|
|
<div className="absolute top-1.5 right-1.5 w-4 h-4 bg-brand-500 rounded-full flex items-center justify-center">
|
|
<Check size={10} className="text-white" />
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-lg">{info.icon}</span>
|
|
<span className="text-sm font-medium text-gray-900 dark:text-white">{info.name}</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Allow OAuth Registration</h4>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">New customers can create accounts via OAuth</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={`${oauthSettings.allowRegistration ? 'bg-brand-600' : 'bg-gray-200 dark:bg-gray-600'} relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand-500`}
|
|
role="switch"
|
|
onClick={() => setOAuthSettings((prev) => ({ ...prev, allowRegistration: !prev.allowRegistration }))}
|
|
>
|
|
<span className={`${oauthSettings.allowRegistration ? 'translate-x-4' : 'translate-x-0'} pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Auto-link by Email</h4>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">Link OAuth accounts to existing accounts by email</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={`${oauthSettings.autoLinkByEmail ? 'bg-brand-600' : 'bg-gray-200 dark:bg-gray-600'} relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand-500`}
|
|
role="switch"
|
|
onClick={() => setOAuthSettings((prev) => ({ ...prev, autoLinkByEmail: !prev.autoLinkByEmail }))}
|
|
>
|
|
<span className={`${oauthSettings.autoLinkByEmail ? 'translate-x-4' : 'translate-x-0'} pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
|
<div className="flex items-start gap-3">
|
|
<AlertCircle size={18} className="text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="font-medium text-amber-800 dark:text-amber-300 text-sm">No OAuth Providers Available</p>
|
|
<p className="text-xs text-amber-700 dark:text-amber-400 mt-1">
|
|
Contact your platform administrator to enable OAuth providers.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Custom OAuth Credentials - Only shown if platform has enabled this permission */}
|
|
{business.canManageOAuthCredentials && (
|
|
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
|
<Key size={20} className="text-purple-500" />
|
|
Custom OAuth Credentials
|
|
</h3>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
Use your own OAuth app credentials for complete branding control
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={handleCredentialsSave}
|
|
disabled={credentialsLoading || updateCredentialsMutation.isPending}
|
|
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<Save size={16} />
|
|
{updateCredentialsMutation.isPending ? 'Saving...' : 'Save'}
|
|
</button>
|
|
</div>
|
|
|
|
{credentialsLoading ? (
|
|
<div className="flex items-center justify-center py-6">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{/* Toggle Custom Credentials */}
|
|
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Use Custom Credentials</h4>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
{useCustomCredentials ? 'Using your custom OAuth credentials' : 'Using platform shared credentials'}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={`${useCustomCredentials ? 'bg-brand-600' : 'bg-gray-200 dark:bg-gray-600'} relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-brand-500`}
|
|
role="switch"
|
|
onClick={() => setUseCustomCredentials(!useCustomCredentials)}
|
|
>
|
|
<span className={`${useCustomCredentials ? 'translate-x-4' : 'translate-x-0'} pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
|
|
</button>
|
|
</div>
|
|
|
|
{useCustomCredentials && (
|
|
<div className="space-y-3">
|
|
{(['google', 'apple', 'facebook', 'linkedin', 'microsoft', 'twitter', 'twitch'] as const).map((provider) => {
|
|
const info = providerInfo[provider];
|
|
const providerCreds = credentials[provider];
|
|
const hasCredentials = providerCreds.client_id || providerCreds.client_secret;
|
|
|
|
return (
|
|
<details key={provider} className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
<summary className="flex items-center justify-between p-3 cursor-pointer bg-gray-50 dark:bg-gray-900/50 hover:bg-gray-100 dark:hover:bg-gray-900">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-lg">{info.icon}</span>
|
|
<span className="font-medium text-gray-900 dark:text-white text-sm">{info.name}</span>
|
|
{hasCredentials && (
|
|
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded">
|
|
Configured
|
|
</span>
|
|
)}
|
|
</div>
|
|
</summary>
|
|
<div className="p-3 space-y-2 border-t border-gray-200 dark:border-gray-700">
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Client ID</label>
|
|
<input
|
|
type="text"
|
|
value={providerCreds.client_id}
|
|
onChange={(e) => updateCredential(provider, 'client_id', e.target.value)}
|
|
placeholder={`Enter ${info.name} Client ID`}
|
|
className="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Client Secret</label>
|
|
<div className="relative">
|
|
<input
|
|
type={showSecrets[`${provider}_secret`] ? 'text' : 'password'}
|
|
value={providerCreds.client_secret}
|
|
onChange={(e) => updateCredential(provider, 'client_secret', e.target.value)}
|
|
placeholder={`Enter ${info.name} Client Secret`}
|
|
className="w-full px-3 py-1.5 pr-8 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 text-sm"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleShowSecret(`${provider}_secret`)}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
>
|
|
{showSecrets[`${provider}_secret`] ? <EyeOff size={14} /> : <Eye size={14} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/* Provider-specific fields */}
|
|
{provider === 'apple' && (
|
|
<>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Team ID</label>
|
|
<input
|
|
type="text"
|
|
value={providerCreds.team_id || ''}
|
|
onChange={(e) => updateCredential(provider, 'team_id', e.target.value)}
|
|
placeholder="Enter Apple Team ID"
|
|
className="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Key ID</label>
|
|
<input
|
|
type="text"
|
|
value={providerCreds.key_id || ''}
|
|
onChange={(e) => updateCredential(provider, 'key_id', e.target.value)}
|
|
placeholder="Enter Apple Key ID"
|
|
className="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 text-sm"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
{provider === 'microsoft' && (
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Tenant ID</label>
|
|
<input
|
|
type="text"
|
|
value={providerCreds.tenant_id || ''}
|
|
onChange={(e) => updateCredential(provider, 'tenant_id', e.target.value)}
|
|
placeholder="Enter Microsoft Tenant ID (or 'common')"
|
|
className="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 text-sm"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</details>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
|
|
{/* Toast */}
|
|
{showToast && (
|
|
<div className="fixed bottom-4 right-4 flex items-center gap-2 px-4 py-3 bg-green-500 text-white rounded-lg shadow-lg">
|
|
<Check size={18} />
|
|
Changes saved successfully
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AuthenticationSettings;
|