mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 11:51:14 -05:00
feat: Implement all authentication compliance phases
This commit is contained in:
257
docs/AUTHENTICATION.md
Normal file
257
docs/AUTHENTICATION.md
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
# ThrillWiki Authentication System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
ThrillWiki implements a comprehensive authentication system following Supabase best practices with multiple sign-in methods, MFA support, and advanced security features.
|
||||||
|
|
||||||
|
## Supported Authentication Methods
|
||||||
|
|
||||||
|
### 1. Email & Password
|
||||||
|
- Standard email/password authentication
|
||||||
|
- Password strength validation (minimum 6 characters)
|
||||||
|
- Email confirmation required by default
|
||||||
|
- Password reset flow with secure email links
|
||||||
|
|
||||||
|
### 2. Magic Link (Passwordless)
|
||||||
|
- One-click email authentication
|
||||||
|
- No password required
|
||||||
|
- Expires after 1 hour
|
||||||
|
- Rate limited to prevent abuse (1 request per 60 seconds)
|
||||||
|
|
||||||
|
### 3. Email OTP (One-Time Password)
|
||||||
|
- 6-digit verification code sent via email
|
||||||
|
- Alternative to Magic Links
|
||||||
|
- Expires after 1 hour
|
||||||
|
- Ideal for mobile apps
|
||||||
|
|
||||||
|
### 4. OAuth Social Login
|
||||||
|
- **Google**: Full profile and email access
|
||||||
|
- **Discord**: Identity and email access
|
||||||
|
- Automatic identity linking with matching emails
|
||||||
|
- Manual identity linking for logged-in users
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
### Multi-Factor Authentication (MFA/TOTP)
|
||||||
|
- App-based authenticator (Google Authenticator, Authy, etc.)
|
||||||
|
- Required for moderators and administrators
|
||||||
|
- QR code enrollment with manual secret fallback
|
||||||
|
- AAL2 (Authenticator Assurance Level 2) enforcement
|
||||||
|
- Automatic session step-up challenges
|
||||||
|
|
||||||
|
### Session Management
|
||||||
|
- Multiple active sessions across devices
|
||||||
|
- Session expiry monitoring with proactive refresh
|
||||||
|
- Device and browser detection
|
||||||
|
- IP address tracking (last 8 chars of hash for privacy)
|
||||||
|
- Revoke individual sessions or all others
|
||||||
|
|
||||||
|
### Sign Out Options
|
||||||
|
- **Global**: Sign out from all devices (default)
|
||||||
|
- **Local**: Sign out from current device only
|
||||||
|
- **Others**: Sign out from all other devices
|
||||||
|
|
||||||
|
### Identity Management
|
||||||
|
- Link multiple OAuth providers to one account
|
||||||
|
- Disconnect OAuth identities (with safety checks)
|
||||||
|
- Add password backup to OAuth-only accounts
|
||||||
|
- Prevents account orphaning (requires at least 1 auth method)
|
||||||
|
- AAL2 required for disconnecting identities
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Critical Security Fixes
|
||||||
|
1. **Email Redirect URLs**: All signup flows include `emailRedirectTo` parameter
|
||||||
|
2. **MFA Bypass Prevention**: Canceling MFA prompt triggers forced sign-out
|
||||||
|
3. **Non-Dismissable MFA Modal**: Cannot close MFA challenge without completing
|
||||||
|
4. **Session Monitoring**: Tokens refreshed 5 minutes before expiry
|
||||||
|
|
||||||
|
### Automatic Identity Linking
|
||||||
|
When a user signs in with OAuth using an email that already exists:
|
||||||
|
- Supabase automatically links the new identity
|
||||||
|
- Removes unconfirmed identities to prevent takeover attacks
|
||||||
|
- Logs linking event to audit trail
|
||||||
|
- Only works with confirmed email addresses
|
||||||
|
|
||||||
|
### Manual Identity Linking
|
||||||
|
Users can manually link additional OAuth providers:
|
||||||
|
```typescript
|
||||||
|
import { linkOAuthIdentity } from '@/lib/identityService';
|
||||||
|
|
||||||
|
// Link a new provider
|
||||||
|
const result = await linkOAuthIdentity('google');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Password Reset Flow
|
||||||
|
For OAuth-only accounts that want to add password:
|
||||||
|
```typescript
|
||||||
|
import { addPasswordToAccount } from '@/lib/identityService';
|
||||||
|
|
||||||
|
// Triggers password setup email
|
||||||
|
const result = await addPasswordToAccount();
|
||||||
|
// User receives email with link to set password
|
||||||
|
```
|
||||||
|
|
||||||
|
## Email OTP Implementation
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
1. Modify Supabase email template to include `{{ .Token }}`
|
||||||
|
2. Use the `EmailOTPInput` component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { EmailOTPInput } from '@/components/auth/EmailOTPInput';
|
||||||
|
|
||||||
|
<EmailOTPInput
|
||||||
|
email={userEmail}
|
||||||
|
onVerify={async (code) => {
|
||||||
|
const { error } = await supabase.auth.verifyOtp({
|
||||||
|
email: userEmail,
|
||||||
|
token: code,
|
||||||
|
type: 'email',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowOTP(false)}
|
||||||
|
onResend={async () => {
|
||||||
|
await supabase.auth.signInWithOtp({ email: userEmail });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Flows
|
||||||
|
|
||||||
|
### Sign Up with Email
|
||||||
|
1. User enters email, password, username, display name
|
||||||
|
2. System validates email (no disposable addresses)
|
||||||
|
3. CAPTCHA verification (if enabled)
|
||||||
|
4. Confirmation email sent to user
|
||||||
|
5. User clicks link → Redirected to `/auth/callback`
|
||||||
|
6. Account confirmed and logged in
|
||||||
|
|
||||||
|
### Sign In with MFA Enrolled
|
||||||
|
1. User enters email and password
|
||||||
|
2. System authenticates credentials
|
||||||
|
3. If MFA enrolled → Shows MFA challenge
|
||||||
|
4. User enters 6-digit TOTP code
|
||||||
|
5. Session upgraded to AAL2
|
||||||
|
6. User granted full access
|
||||||
|
|
||||||
|
### Linking Additional Provider
|
||||||
|
1. User navigates to Settings → Security
|
||||||
|
2. Clicks "Connect" on desired OAuth provider
|
||||||
|
3. Completes OAuth flow with provider
|
||||||
|
4. Returns to ThrillWiki with linked identity
|
||||||
|
5. Can now sign in with either method
|
||||||
|
|
||||||
|
### Disconnecting Provider
|
||||||
|
1. User navigates to Settings → Security
|
||||||
|
2. Clicks "Disconnect" on OAuth provider
|
||||||
|
3. System verifies:
|
||||||
|
- Not the last identity
|
||||||
|
- Has password backup OR another OAuth provider
|
||||||
|
- User has AAL2 session (MFA verified if enrolled)
|
||||||
|
4. Identity disconnected and logged to audit
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Common Error Codes
|
||||||
|
- `email_exists`: Email already registered
|
||||||
|
- `invalid_credentials`: Wrong email/password
|
||||||
|
- `email_not_confirmed`: Email not verified yet
|
||||||
|
- `mfa_verification_failed`: Wrong TOTP code
|
||||||
|
- `identity_already_exists`: Provider already linked
|
||||||
|
- `single_identity_not_deletable`: Cannot remove last auth method
|
||||||
|
- `insufficient_aal`: MFA verification required
|
||||||
|
|
||||||
|
### User-Friendly Messages
|
||||||
|
All errors are converted to user-friendly messages:
|
||||||
|
```typescript
|
||||||
|
import { handleError } from '@/lib/errorHandler';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Auth operation
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, { action: 'Sign In' });
|
||||||
|
// Shows toast with friendly message
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Email Templates
|
||||||
|
Configure in Supabase Dashboard → Authentication → Email Templates
|
||||||
|
|
||||||
|
**Magic Link Template:**
|
||||||
|
```html
|
||||||
|
<h2>Magic Link</h2>
|
||||||
|
<p>Follow this link to login:</p>
|
||||||
|
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Log In</a></p>
|
||||||
|
```
|
||||||
|
|
||||||
|
**OTP Template:**
|
||||||
|
```html
|
||||||
|
<h2>Verification Code</h2>
|
||||||
|
<p>Your verification code is:</p>
|
||||||
|
<h1 style="font-size: 32px; letter-spacing: 8px;">{{ .Token }}</h1>
|
||||||
|
<p>This code expires in 1 hour.</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Redirect URLs
|
||||||
|
Configure in Supabase Dashboard → Authentication → URL Configuration:
|
||||||
|
- **Site URL**: `https://www.thrillwiki.com`
|
||||||
|
- **Redirect URLs**:
|
||||||
|
- `https://www.thrillwiki.com/auth/callback`
|
||||||
|
- `http://localhost:8080/auth/callback` (development)
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### For Users
|
||||||
|
- Enable MFA for additional security
|
||||||
|
- Link multiple providers for backup access
|
||||||
|
- Use strong, unique passwords (if using email/password)
|
||||||
|
- Review active sessions regularly
|
||||||
|
- Sign out from unfamiliar devices
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
- Always include `emailRedirectTo` in signup calls
|
||||||
|
- Validate emails to prevent disposable addresses
|
||||||
|
- Never log sensitive data (passwords, tokens)
|
||||||
|
- Use AAL2 checks for critical operations
|
||||||
|
- Implement proper error handling with user-friendly messages
|
||||||
|
- Test all authentication flows thoroughly
|
||||||
|
|
||||||
|
## Audit Logging
|
||||||
|
|
||||||
|
All authentication events are logged:
|
||||||
|
- Sign in/sign out
|
||||||
|
- MFA enrollment/verification
|
||||||
|
- Identity linking/unlinking
|
||||||
|
- Password changes
|
||||||
|
- Session revocations
|
||||||
|
|
||||||
|
Access logs via `admin_audit_log` table or Settings → Security → Sessions.
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Sign up with email/password
|
||||||
|
- [ ] Confirm email and log in
|
||||||
|
- [ ] Reset password
|
||||||
|
- [ ] Magic link sign in
|
||||||
|
- [ ] OAuth sign in (Google)
|
||||||
|
- [ ] OAuth sign in (Discord)
|
||||||
|
- [ ] Enroll MFA
|
||||||
|
- [ ] Sign in with MFA
|
||||||
|
- [ ] Cancel MFA (should sign out)
|
||||||
|
- [ ] Link additional OAuth provider
|
||||||
|
- [ ] Disconnect OAuth provider
|
||||||
|
- [ ] Add password to OAuth-only account
|
||||||
|
- [ ] Revoke session
|
||||||
|
- [ ] Sign out (all scopes)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues or questions about authentication:
|
||||||
|
- Check Supabase auth logs in dashboard
|
||||||
|
- Review browser console for client errors
|
||||||
|
- Check network requests in DevTools
|
||||||
|
- Verify email template configuration
|
||||||
|
- Confirm redirect URLs are correct
|
||||||
@@ -244,6 +244,7 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
|||||||
email: formData.email,
|
email: formData.email,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
options: {
|
options: {
|
||||||
|
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
||||||
data: {
|
data: {
|
||||||
username: formData.username,
|
username: formData.username,
|
||||||
display_name: formData.displayName
|
display_name: formData.displayName
|
||||||
|
|||||||
101
src/components/auth/EmailOTPInput.tsx
Normal file
101
src/components/auth/EmailOTPInput.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { Mail } from 'lucide-react';
|
||||||
|
|
||||||
|
interface EmailOTPInputProps {
|
||||||
|
email: string;
|
||||||
|
onVerify: (code: string) => Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
onResend: () => Promise<void>;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmailOTPInput({
|
||||||
|
email,
|
||||||
|
onVerify,
|
||||||
|
onCancel,
|
||||||
|
onResend,
|
||||||
|
loading = false
|
||||||
|
}: EmailOTPInputProps) {
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [resending, setResending] = useState(false);
|
||||||
|
|
||||||
|
const handleVerify = async () => {
|
||||||
|
if (code.length === 6) {
|
||||||
|
await onVerify(code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResend = async () => {
|
||||||
|
setResending(true);
|
||||||
|
try {
|
||||||
|
await onResend();
|
||||||
|
setCode(''); // Reset code input
|
||||||
|
} finally {
|
||||||
|
setResending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert>
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
We've sent a 6-digit verification code to <strong>{email}</strong>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="text-sm text-muted-foreground text-center">
|
||||||
|
Enter the 6-digit code
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InputOTP
|
||||||
|
maxLength={6}
|
||||||
|
value={code}
|
||||||
|
onChange={setCode}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
|
||||||
|
<div className="flex gap-2 w-full">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={loading || resending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleVerify}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={code.length !== 6 || loading || resending}
|
||||||
|
>
|
||||||
|
{loading ? 'Verifying...' : 'Verify'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleResend}
|
||||||
|
disabled={loading || resending}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{resending ? 'Sending...' : "Didn't receive a code? Resend"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
225
src/components/settings/IdentityManagement.tsx
Normal file
225
src/components/settings/IdentityManagement.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { Link, Unlink, Shield, AlertCircle } from 'lucide-react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import {
|
||||||
|
getUserIdentities,
|
||||||
|
disconnectIdentity,
|
||||||
|
linkOAuthIdentity,
|
||||||
|
checkDisconnectSafety,
|
||||||
|
addPasswordToAccount
|
||||||
|
} from '@/lib/identityService';
|
||||||
|
import type { UserIdentity, OAuthProvider } from '@/types/identity';
|
||||||
|
|
||||||
|
export function IdentityManagement() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [identities, setIdentities] = useState<UserIdentity[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadIdentities();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadIdentities = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getUserIdentities();
|
||||||
|
setIdentities(data);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = async (provider: OAuthProvider) => {
|
||||||
|
// Safety check
|
||||||
|
const safety = await checkDisconnectSafety(provider);
|
||||||
|
if (!safety.canDisconnect) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: 'Cannot Disconnect',
|
||||||
|
description: safety.reason === 'last_identity'
|
||||||
|
? 'This is your only sign-in method. Add a password or another provider first.'
|
||||||
|
: 'Please add a password before disconnecting your last social login.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionLoading(provider);
|
||||||
|
const result = await disconnectIdentity(provider);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast({
|
||||||
|
title: 'Provider Disconnected',
|
||||||
|
description: `${provider} has been removed from your account.`,
|
||||||
|
});
|
||||||
|
await loadIdentities();
|
||||||
|
} else if (result.requiresAAL2) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: 'MFA Required',
|
||||||
|
description: result.error || 'Please verify your identity with MFA.',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: 'Failed to Disconnect',
|
||||||
|
description: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionLoading(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLink = async (provider: OAuthProvider) => {
|
||||||
|
setActionLoading(provider);
|
||||||
|
const result = await linkOAuthIdentity(provider);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// OAuth redirect will happen automatically
|
||||||
|
toast({
|
||||||
|
title: 'Redirecting...',
|
||||||
|
description: `Opening ${provider} sign-in window...`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: 'Failed to Link',
|
||||||
|
description: result.error,
|
||||||
|
});
|
||||||
|
setActionLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddPassword = async () => {
|
||||||
|
setActionLoading('password');
|
||||||
|
const result = await addPasswordToAccount();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast({
|
||||||
|
title: 'Check Your Email',
|
||||||
|
description: `We've sent a password setup link to ${result.email}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: 'Failed to Add Password',
|
||||||
|
description: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionLoading(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasProvider = (provider: string) =>
|
||||||
|
identities.some(i => i.provider === provider);
|
||||||
|
|
||||||
|
const hasPassword = hasProvider('email');
|
||||||
|
|
||||||
|
const providers: { id: OAuthProvider; label: string; icon: string }[] = [
|
||||||
|
{ id: 'google', label: 'Google', icon: 'G' },
|
||||||
|
{ id: 'discord', label: 'Discord', icon: 'D' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Connected Accounts</CardTitle>
|
||||||
|
<CardDescription>Loading...</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Link className="w-5 h-5" />
|
||||||
|
Connected Accounts
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Link multiple sign-in methods to your account for easy access
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{identities.length === 1 && !hasPassword && (
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Add a password as a backup sign-in method
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Password Authentication */}
|
||||||
|
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<Shield className="w-5 h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Email & Password</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{hasPassword ? 'Connected' : 'Not set up'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!hasPassword && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddPassword}
|
||||||
|
disabled={actionLoading === 'password'}
|
||||||
|
>
|
||||||
|
<Link className="w-4 h-4 mr-2" />
|
||||||
|
{actionLoading === 'password' ? 'Setting up...' : 'Add Password'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OAuth Providers */}
|
||||||
|
{providers.map((provider) => {
|
||||||
|
const isConnected = hasProvider(provider.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={provider.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center font-bold">
|
||||||
|
{provider.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{provider.label}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{isConnected ? 'Connected' : 'Not connected'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={isConnected ? 'destructive' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => isConnected
|
||||||
|
? handleDisconnect(provider.id)
|
||||||
|
: handleLink(provider.id)
|
||||||
|
}
|
||||||
|
disabled={actionLoading === provider.id}
|
||||||
|
>
|
||||||
|
{isConnected ? (
|
||||||
|
<>
|
||||||
|
<Unlink className="w-4 h-4 mr-2" />
|
||||||
|
{actionLoading === provider.id ? 'Disconnecting...' : 'Disconnect'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link className="w-4 h-4 mr-2" />
|
||||||
|
{actionLoading === provider.id ? 'Connecting...' : 'Connect'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -261,77 +261,77 @@ export function SecurityTab() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Connected Accounts */}
|
{/* Identity Management Section */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Globe className="w-5 h-5" />
|
<Globe className="w-5 h-5" />
|
||||||
<CardTitle>Connected Accounts</CardTitle>
|
<CardTitle>Connected Accounts</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Manage your social login connections for easier access to your account.
|
Manage your social login connections for easier access to your account.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{loadingIdentities ? (
|
{loadingIdentities ? (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
connectedAccounts.map(account => {
|
connectedAccounts.map(account => {
|
||||||
const isConnected = !!account.identity;
|
const isConnected = !!account.identity;
|
||||||
const isDisconnecting = disconnectingProvider === account.provider;
|
const isDisconnecting = disconnectingProvider === account.provider;
|
||||||
const email = account.identity?.identity_data?.email;
|
const email = account.identity?.identity_data?.email;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={account.provider} className="flex items-center justify-between p-4 border rounded-lg">
|
<div key={account.provider} className="flex items-center justify-between p-4 border rounded-lg">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
|
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
|
||||||
{account.icon}
|
{account.icon}
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium capitalize">{account.provider}</p>
|
|
||||||
{isConnected && email && (
|
|
||||||
<p className="text-sm text-muted-foreground">{email}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div>
|
||||||
{isConnected ? (
|
<p className="font-medium capitalize">{account.provider}</p>
|
||||||
<>
|
{isConnected && email && (
|
||||||
<Badge variant="secondary">Connected</Badge>
|
<p className="text-sm text-muted-foreground">{email}</p>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleUnlinkSocial(account.provider)}
|
|
||||||
disabled={isDisconnecting}
|
|
||||||
>
|
|
||||||
{isDisconnecting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Disconnecting...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Disconnect'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleSocialLogin(account.provider)}
|
|
||||||
>
|
|
||||||
Connect
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="flex items-center gap-2">
|
||||||
})
|
{isConnected ? (
|
||||||
)}
|
<>
|
||||||
</CardContent>
|
<Badge variant="secondary">Connected</Badge>
|
||||||
</Card>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleUnlinkSocial(account.provider)}
|
||||||
|
disabled={isDisconnecting}
|
||||||
|
>
|
||||||
|
{isDisconnecting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Disconnecting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Disconnect'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSocialLogin(account.provider)}
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Two-Factor Authentication - Full Width */}
|
{/* Two-Factor Authentication - Full Width */}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface AuthContextType {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
pendingEmail: string | null;
|
pendingEmail: string | null;
|
||||||
sessionError: string | null;
|
sessionError: string | null;
|
||||||
signOut: () => Promise<void>;
|
signOut: (scope?: 'global' | 'local' | 'others') => Promise<void>;
|
||||||
verifySession: () => Promise<boolean>;
|
verifySession: () => Promise<boolean>;
|
||||||
clearPendingEmail: () => void;
|
clearPendingEmail: () => void;
|
||||||
checkAalStepUp: () => Promise<CheckAalResult>;
|
checkAalStepUp: () => Promise<CheckAalResult>;
|
||||||
@@ -123,6 +123,24 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
|
|||||||
await supabase.auth.signOut();
|
await supabase.auth.signOut();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enhanced session monitoring: Proactively refresh tokens before expiry
|
||||||
|
const expiresAt = session.expires_at;
|
||||||
|
if (expiresAt) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const timeUntilExpiry = expiresAt - now;
|
||||||
|
|
||||||
|
// Refresh 5 minutes (300 seconds) before expiry
|
||||||
|
if (timeUntilExpiry < 300 && timeUntilExpiry > 0) {
|
||||||
|
authLog('[Auth] Token expiring soon, refreshing session...');
|
||||||
|
const { error } = await supabase.auth.refreshSession();
|
||||||
|
if (error) {
|
||||||
|
authError('[Auth] Session refresh failed:', error);
|
||||||
|
} else {
|
||||||
|
authLog('[Auth] Session refreshed successfully');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setAal(null);
|
setAal(null);
|
||||||
}
|
}
|
||||||
@@ -218,12 +236,23 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const signOut = async () => {
|
const signOut = async (scope: 'global' | 'local' | 'others' = 'global') => {
|
||||||
authLog('[Auth] Signing out...');
|
authLog('[Auth] Signing out with scope:', scope);
|
||||||
const result = await signOutUser();
|
|
||||||
if (!result.success) {
|
try {
|
||||||
authError('Error signing out:', result.error);
|
const { error } = await supabase.auth.signOut({ scope });
|
||||||
throw new Error(result.error);
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Clear all auth flags (only on global/local sign out)
|
||||||
|
if (scope !== 'others') {
|
||||||
|
clearAllAuthFlags();
|
||||||
|
}
|
||||||
|
|
||||||
|
authLog('[Auth] Sign out successful');
|
||||||
|
} catch (error) {
|
||||||
|
authError('[Auth] Error signing out:', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -223,6 +223,76 @@ export async function connectIdentity(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link an OAuth identity to the logged-in user's account (Manual Linking)
|
||||||
|
* Requires user to be authenticated
|
||||||
|
*/
|
||||||
|
export async function linkOAuthIdentity(
|
||||||
|
provider: OAuthProvider
|
||||||
|
): Promise<IdentityOperationResult> {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.auth.linkIdentity({
|
||||||
|
provider
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Log audit event
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (user) {
|
||||||
|
await logIdentityChange(user.id, 'identity_linked', {
|
||||||
|
provider,
|
||||||
|
method: 'manual',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = getErrorMessage(error);
|
||||||
|
logger.error('Failed to link identity', {
|
||||||
|
action: 'identity_link',
|
||||||
|
provider,
|
||||||
|
error: errorMsg
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMsg
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log when automatic identity linking occurs
|
||||||
|
* Called internally when Supabase automatically links identities
|
||||||
|
*/
|
||||||
|
export async function logAutomaticIdentityLinking(
|
||||||
|
userId: string,
|
||||||
|
provider: OAuthProvider,
|
||||||
|
email: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await logIdentityChange(userId, 'identity_auto_linked', {
|
||||||
|
provider,
|
||||||
|
email,
|
||||||
|
method: 'automatic',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Automatic identity linking logged', {
|
||||||
|
userId,
|
||||||
|
provider,
|
||||||
|
action: 'identity_auto_linked'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to log automatic identity linking', {
|
||||||
|
userId,
|
||||||
|
provider,
|
||||||
|
error: getErrorMessage(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add password authentication to an OAuth-only account
|
* Add password authentication to an OAuth-only account
|
||||||
|
|||||||
@@ -316,6 +316,7 @@ export default function Auth() {
|
|||||||
email: formData.email,
|
email: formData.email,
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
options: {
|
options: {
|
||||||
|
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
||||||
captchaToken: tokenToUse,
|
captchaToken: tokenToUse,
|
||||||
data: {
|
data: {
|
||||||
username: formData.username,
|
username: formData.username,
|
||||||
|
|||||||
Reference in New Issue
Block a user