823 lines
29 KiB
TypeScript
823 lines
29 KiB
TypeScript
import { AppLayout } from '@/components/AppLayout'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { useAuth } from '@/contexts/AuthContext'
|
|
import { useTheme } from '@/hooks/use-theme'
|
|
import { useLocale } from '@/hooks/use-locale'
|
|
import { getSupportedTimezones } from '@/utils/locale'
|
|
import {
|
|
type ApiTokenStatusResponse,
|
|
type UserProvider,
|
|
authService,
|
|
} from '@/lib/api/services/auth'
|
|
import {
|
|
CheckCircle2,
|
|
Copy,
|
|
Eye,
|
|
EyeOff,
|
|
Github,
|
|
Key,
|
|
Mail,
|
|
Palette,
|
|
Shield,
|
|
Trash2,
|
|
User,
|
|
} from 'lucide-react'
|
|
import { useEffect, useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
import { formatDate } from '@/utils/format-date'
|
|
|
|
export function AccountPage() {
|
|
const { user, setUser } = useAuth()
|
|
const { theme, setTheme } = useTheme()
|
|
const { locale, timezone, setLocale, setTimezone } = useLocale()
|
|
|
|
// Profile state
|
|
const [profileName, setProfileName] = useState('')
|
|
const [profileSaving, setProfileSaving] = useState(false)
|
|
|
|
// Password state
|
|
const [passwordData, setPasswordData] = useState({
|
|
current_password: '',
|
|
new_password: '',
|
|
confirm_password: '',
|
|
})
|
|
const [passwordSaving, setPasswordSaving] = useState(false)
|
|
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
|
|
const [showNewPassword, setShowNewPassword] = useState(false)
|
|
|
|
// API Token state
|
|
const [apiTokenStatus, setApiTokenStatus] =
|
|
useState<ApiTokenStatusResponse | null>(null)
|
|
const [apiTokenLoading, setApiTokenLoading] = useState(true)
|
|
const [generatedToken, setGeneratedToken] = useState('')
|
|
const [showGeneratedToken, setShowGeneratedToken] = useState(false)
|
|
const [tokenExpireDays, setTokenExpireDays] = useState('365')
|
|
|
|
// Providers state
|
|
const [providers, setProviders] = useState<UserProvider[]>([])
|
|
const [providersLoading, setProvidersLoading] = useState(true)
|
|
|
|
// Prepare timezone options for combobox
|
|
const timezoneOptions: ComboboxOption[] = getSupportedTimezones().map((tz) => ({
|
|
value: tz,
|
|
label: tz.replace('_', ' '),
|
|
searchValue: tz.replace('_', ' ')
|
|
}))
|
|
|
|
useEffect(() => {
|
|
if (user) {
|
|
setProfileName(user.name)
|
|
}
|
|
loadApiTokenStatus()
|
|
loadProviders()
|
|
}, [user])
|
|
|
|
const loadApiTokenStatus = async () => {
|
|
try {
|
|
const status = await authService.getApiTokenStatus()
|
|
setApiTokenStatus(status)
|
|
} catch (error) {
|
|
console.error('Failed to load API token status:', error)
|
|
} finally {
|
|
setApiTokenLoading(false)
|
|
}
|
|
}
|
|
|
|
const loadProviders = async () => {
|
|
try {
|
|
const userProviders = await authService.getUserProviders()
|
|
setProviders(userProviders)
|
|
} catch (error) {
|
|
console.error('Failed to load providers:', error)
|
|
setProviders([])
|
|
} finally {
|
|
setProvidersLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleProfileSave = async () => {
|
|
if (!user || !profileName.trim()) return
|
|
|
|
setProfileSaving(true)
|
|
try {
|
|
const updatedUser = await authService.updateProfile({
|
|
name: profileName.trim(),
|
|
})
|
|
setUser?.(updatedUser)
|
|
toast.success('Profile updated successfully')
|
|
} catch (error) {
|
|
toast.error('Failed to update profile')
|
|
console.error('Profile update error:', error)
|
|
} finally {
|
|
setProfileSaving(false)
|
|
}
|
|
}
|
|
|
|
const handlePasswordChange = async () => {
|
|
// Check if user has password authentication from providers
|
|
const hasPasswordProvider = providers.some(
|
|
provider => provider.provider === 'password',
|
|
)
|
|
|
|
// Validate required fields
|
|
if (hasPasswordProvider && !passwordData.current_password) {
|
|
toast.error('Current password is required')
|
|
return
|
|
}
|
|
|
|
if (!passwordData.new_password) {
|
|
toast.error('New password is required')
|
|
return
|
|
}
|
|
|
|
if (passwordData.new_password !== passwordData.confirm_password) {
|
|
toast.error('New passwords do not match')
|
|
return
|
|
}
|
|
|
|
if (passwordData.new_password.length < 8) {
|
|
toast.error('New password must be at least 8 characters long')
|
|
return
|
|
}
|
|
|
|
setPasswordSaving(true)
|
|
try {
|
|
await authService.changePassword({
|
|
current_password: hasPasswordProvider
|
|
? passwordData.current_password
|
|
: undefined,
|
|
new_password: passwordData.new_password,
|
|
})
|
|
setPasswordData({
|
|
current_password: '',
|
|
new_password: '',
|
|
confirm_password: '',
|
|
})
|
|
toast.success(
|
|
hasPasswordProvider
|
|
? 'Password changed successfully'
|
|
: 'Password set successfully',
|
|
)
|
|
|
|
// Reload providers since password status might have changed
|
|
loadProviders()
|
|
} catch (error) {
|
|
toast.error('Failed to change password')
|
|
console.error('Password change error:', error)
|
|
} finally {
|
|
setPasswordSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleGenerateApiToken = async () => {
|
|
try {
|
|
const response = await authService.generateApiToken({
|
|
expires_days: parseInt(tokenExpireDays),
|
|
})
|
|
setGeneratedToken(response.api_token)
|
|
setShowGeneratedToken(true)
|
|
await loadApiTokenStatus()
|
|
toast.success('API token generated successfully')
|
|
} catch (error) {
|
|
toast.error('Failed to generate API token')
|
|
console.error('API token generation error:', error)
|
|
}
|
|
}
|
|
|
|
const handleDeleteApiToken = async () => {
|
|
try {
|
|
await authService.deleteApiToken()
|
|
await loadApiTokenStatus()
|
|
toast.success('API token deleted successfully')
|
|
} catch (error) {
|
|
toast.error('Failed to delete API token')
|
|
console.error('API token deletion error:', error)
|
|
}
|
|
}
|
|
|
|
const copyToClipboard = (text: string) => {
|
|
navigator.clipboard.writeText(text)
|
|
toast.success('Copied to clipboard')
|
|
}
|
|
|
|
const getProviderIcon = (provider: string) => {
|
|
switch (provider.toLowerCase()) {
|
|
case 'github':
|
|
return <Github className="h-4 w-4" />
|
|
case 'google':
|
|
return <Mail className="h-4 w-4" />
|
|
case 'password':
|
|
return <Key className="h-4 w-4" />
|
|
default:
|
|
return <Shield className="h-4 w-4" />
|
|
}
|
|
}
|
|
|
|
if (!user) {
|
|
return (
|
|
<AppLayout
|
|
breadcrumb={{
|
|
items: [{ label: 'Dashboard', href: '/' }, { label: 'Account' }],
|
|
}}
|
|
>
|
|
<div className="flex-1 rounded-xl bg-muted/50 p-4">
|
|
<Skeleton className="h-8 w-48 mb-6" />
|
|
<div className="grid gap-6 md:grid-cols-2">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Card key={i}>
|
|
<CardHeader>
|
|
<Skeleton className="h-6 w-32" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-10 w-full" />
|
|
<Skeleton className="h-10 w-full" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</AppLayout>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<AppLayout
|
|
breadcrumb={{
|
|
items: [{ label: 'Dashboard', href: '/' }, { label: 'Account' }],
|
|
}}
|
|
>
|
|
<div className="flex-1 space-y-6">
|
|
<div className="flex justify-between items-center">
|
|
<h1 className="text-3xl font-bold">Account Settings</h1>
|
|
</div>
|
|
|
|
<div className="grid gap-6 md:grid-cols-2">
|
|
{/* Profile Information */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<User className="h-5 w-5" />
|
|
Profile Information
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">Email Address</Label>
|
|
<Input
|
|
id="email"
|
|
value={user.email}
|
|
disabled
|
|
className="bg-muted"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Email cannot be changed
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">Display Name</Label>
|
|
<Input
|
|
id="name"
|
|
value={profileName}
|
|
onChange={e => setProfileName(e.target.value)}
|
|
placeholder="Enter your display name"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Account Details</Label>
|
|
<div className="text-sm text-muted-foreground space-y-1">
|
|
<div>
|
|
Role:{' '}
|
|
<Badge
|
|
variant={
|
|
user.role === 'admin' ? 'destructive' : 'secondary'
|
|
}
|
|
>
|
|
{user.role}
|
|
</Badge>
|
|
</div>
|
|
<div>
|
|
Credits:{' '}
|
|
<span className="font-medium">
|
|
{user.credits.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
Plan: <span className="font-medium">{user.plan.name}</span>
|
|
</div>
|
|
<div>
|
|
Member since:{' '}
|
|
{formatDate(user.created_at)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={handleProfileSave}
|
|
disabled={profileSaving || profileName === user.name}
|
|
className="w-full"
|
|
>
|
|
{profileSaving ? 'Saving...' : 'Save Changes'}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Theme Settings */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Palette className="h-5 w-5" />
|
|
Appearance
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>Theme Preference</Label>
|
|
<Select
|
|
value={theme}
|
|
onValueChange={(value: 'light' | 'dark' | 'system') =>
|
|
setTheme(value)
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="light">Light</SelectItem>
|
|
<SelectItem value="dark">Dark</SelectItem>
|
|
<SelectItem value="system">System</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">
|
|
Choose how the interface appears to you
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Language Preference</Label>
|
|
<Select
|
|
value={locale}
|
|
onValueChange={(value: 'en-US' | 'fr-FR') =>
|
|
setLocale(value)
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="en-US">English (US)</SelectItem>
|
|
<SelectItem value="fr-FR">Français (FR)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">
|
|
Choose your preferred language for the interface
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Timezone</Label>
|
|
<Combobox
|
|
value={timezone}
|
|
onValueChange={setTimezone}
|
|
options={timezoneOptions}
|
|
placeholder="Select timezone..."
|
|
searchPlaceholder="Search timezone..."
|
|
emptyMessage="No timezone found."
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Choose your timezone for date and time display
|
|
</p>
|
|
</div>
|
|
|
|
<div className="pt-4 space-y-1">
|
|
<div className="text-sm text-muted-foreground">
|
|
Current theme:{' '}
|
|
<span className="font-medium capitalize">{theme}</span>
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
Current language:{' '}
|
|
<span className="font-medium">{locale === 'en-US' ? 'English (US)' : 'Français (FR)'}</span>
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
Current timezone:{' '}
|
|
<span className="font-medium">{timezone.replace('_', ' ')}</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Password Management */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Shield className="h-5 w-5" />
|
|
Security
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{providers.some(provider => provider.provider === 'password') ? (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="current-password">Current Password</Label>
|
|
<div className="relative">
|
|
<Input
|
|
id="current-password"
|
|
type={showCurrentPassword ? 'text' : 'password'}
|
|
value={passwordData.current_password}
|
|
onChange={e =>
|
|
setPasswordData(prev => ({
|
|
...prev,
|
|
current_password: e.target.value,
|
|
}))
|
|
}
|
|
placeholder="Enter current password"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
|
onClick={() =>
|
|
setShowCurrentPassword(!showCurrentPassword)
|
|
}
|
|
>
|
|
{showCurrentPassword ? (
|
|
<EyeOff className="h-4 w-4" />
|
|
) : (
|
|
<Eye className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="new-password">New Password</Label>
|
|
<div className="relative">
|
|
<Input
|
|
id="new-password"
|
|
type={showNewPassword ? 'text' : 'password'}
|
|
value={passwordData.new_password}
|
|
onChange={e =>
|
|
setPasswordData(prev => ({
|
|
...prev,
|
|
new_password: e.target.value,
|
|
}))
|
|
}
|
|
placeholder="Enter new password"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
|
onClick={() => setShowNewPassword(!showNewPassword)}
|
|
>
|
|
{showNewPassword ? (
|
|
<EyeOff className="h-4 w-4" />
|
|
) : (
|
|
<Eye className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="confirm-password">
|
|
Confirm New Password
|
|
</Label>
|
|
<Input
|
|
id="confirm-password"
|
|
type="password"
|
|
value={passwordData.confirm_password}
|
|
onChange={e =>
|
|
setPasswordData(prev => ({
|
|
...prev,
|
|
confirm_password: e.target.value,
|
|
}))
|
|
}
|
|
placeholder="Confirm new password"
|
|
/>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={handlePasswordChange}
|
|
disabled={passwordSaving}
|
|
className="w-full"
|
|
>
|
|
{passwordSaving
|
|
? 'Changing Password...'
|
|
: 'Change Password'}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
|
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
|
💡 <strong>Set up password authentication</strong>
|
|
<br />
|
|
You signed up with OAuth and don't have a password yet.
|
|
Set one now to enable password login.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="new-password">Create Password</Label>
|
|
<div className="relative">
|
|
<Input
|
|
id="new-password"
|
|
type={showNewPassword ? 'text' : 'password'}
|
|
value={passwordData.new_password}
|
|
onChange={e =>
|
|
setPasswordData(prev => ({
|
|
...prev,
|
|
new_password: e.target.value,
|
|
}))
|
|
}
|
|
placeholder="Enter your new password"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
|
onClick={() => setShowNewPassword(!showNewPassword)}
|
|
>
|
|
{showNewPassword ? (
|
|
<EyeOff className="h-4 w-4" />
|
|
) : (
|
|
<Eye className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="confirm-password">Confirm Password</Label>
|
|
<Input
|
|
id="confirm-password"
|
|
type="password"
|
|
value={passwordData.confirm_password}
|
|
onChange={e =>
|
|
setPasswordData(prev => ({
|
|
...prev,
|
|
confirm_password: e.target.value,
|
|
}))
|
|
}
|
|
placeholder="Confirm your password"
|
|
/>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={handlePasswordChange}
|
|
disabled={passwordSaving}
|
|
className="w-full"
|
|
>
|
|
{passwordSaving ? 'Setting Password...' : 'Set Password'}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* API Token Management */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Key className="h-5 w-5" />
|
|
API Token
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{apiTokenLoading ? (
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-10 w-full" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{apiTokenStatus?.has_token ? (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
|
<span>API Token Active</span>
|
|
{apiTokenStatus.expires_at && (
|
|
<span className="text-muted-foreground">
|
|
(Expires:{' '}
|
|
{formatDate(apiTokenStatus.expires_at, false)}
|
|
)
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Button
|
|
onClick={handleDeleteApiToken}
|
|
variant="destructive"
|
|
size="sm"
|
|
className="w-full"
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-2" />
|
|
Delete Token
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="expire-days">Token Expiration</Label>
|
|
<Select
|
|
value={tokenExpireDays}
|
|
onValueChange={setTokenExpireDays}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="30">30 days</SelectItem>
|
|
<SelectItem value="90">90 days</SelectItem>
|
|
<SelectItem value="365">1 year</SelectItem>
|
|
<SelectItem value="3650">10 years</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Button
|
|
onClick={handleGenerateApiToken}
|
|
className="w-full"
|
|
>
|
|
Generate API Token
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<div className="text-xs text-muted-foreground">
|
|
API tokens allow external applications to access your account
|
|
programmatically
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Authentication Providers */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Shield className="h-5 w-5" />
|
|
Authentication Methods
|
|
</CardTitle>
|
|
<p className="text-sm text-muted-foreground">
|
|
Available methods to sign in to your account. Use any of these to
|
|
access your account.
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{providersLoading ? (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 2 }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex items-center justify-between p-3 border rounded-lg"
|
|
>
|
|
<Skeleton className="h-4 w-24" />
|
|
<Skeleton className="h-8 w-20" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{/* All Authentication Providers from API */}
|
|
{providers.map(provider => {
|
|
const isOAuth = provider.provider !== 'password'
|
|
|
|
return (
|
|
<div
|
|
key={provider.provider}
|
|
className="flex items-center justify-between p-3 border rounded-lg"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{getProviderIcon(provider.provider)}
|
|
<span className="font-medium">
|
|
{provider.display_name}
|
|
</span>
|
|
<Badge variant="secondary">
|
|
{isOAuth ? 'OAuth' : 'Password Authentication'}
|
|
</Badge>
|
|
{provider.connected_at && (
|
|
<span className="text-xs text-muted-foreground">
|
|
Connected{' '}
|
|
{new Date(
|
|
provider.connected_at,
|
|
).toLocaleDateString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Badge
|
|
variant="outline"
|
|
className="text-green-700 border-green-200 bg-green-50 dark:text-green-400 dark:border-green-800 dark:bg-green-900/20"
|
|
>
|
|
Available
|
|
</Badge>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* API Token Provider */}
|
|
{apiTokenStatus?.has_token && (
|
|
<div className="flex items-center justify-between p-3 border rounded-lg">
|
|
<div className="flex items-center gap-2">
|
|
<Key className="h-4 w-4" />
|
|
<span className="font-medium">API Token</span>
|
|
<Badge variant="secondary">API Access</Badge>
|
|
{apiTokenStatus.expires_at && (
|
|
<span className="text-xs text-muted-foreground">
|
|
Expires{' '}
|
|
{new Date(
|
|
apiTokenStatus.expires_at,
|
|
).toLocaleDateString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Badge
|
|
variant="outline"
|
|
className="text-blue-700 border-blue-200 bg-blue-50 dark:text-blue-400 dark:border-blue-800 dark:bg-blue-900/20"
|
|
>
|
|
Available
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
|
|
{providers.length === 0 && !apiTokenStatus?.has_token && (
|
|
<div className="text-center py-6 text-muted-foreground">
|
|
No authentication methods configured
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Generated Token Dialog */}
|
|
<Dialog open={showGeneratedToken} onOpenChange={setShowGeneratedToken}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>API Token Generated</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>Your API Token</Label>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
value={generatedToken}
|
|
readOnly
|
|
className="font-mono text-sm"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => copyToClipboard(generatedToken)}
|
|
>
|
|
<Copy className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="bg-yellow-50 dark:bg-yellow-900/20 p-3 rounded-lg">
|
|
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
|
⚠️ <strong>Important:</strong> This token will only be shown
|
|
once. Copy it now and store it securely.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
onClick={() => setShowGeneratedToken(false)}
|
|
className="w-full"
|
|
>
|
|
I've Saved My Token
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</AppLayout>
|
|
)
|
|
}
|