improvement(settings): ui/ux (#1021)

* completed general

* completed environment

* completed account; updated general and environment

* fixed skeleton

* finished credentials

* finished privacy; adjusted all colors and styling

* added reset password

* refactor: team and subscription

* finalized subscription settings

* fixed copilot key UI
This commit is contained in:
Emir Karabeg
2025-08-18 20:57:29 -07:00
committed by GitHub
parent b40fa3aa6e
commit 72e3efa875
52 changed files with 2501 additions and 1842 deletions

View File

@@ -0,0 +1,120 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { db } from '@/db'
import { user } from '@/db/schema'
const logger = createLogger('UpdateUserProfileAPI')
// Schema for updating user profile
const UpdateProfileSchema = z
.object({
name: z.string().min(1, 'Name is required').optional(),
})
.refine((data) => data.name !== undefined, {
message: 'Name field must be provided',
})
export const dynamic = 'force-dynamic'
export async function PATCH(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized profile update attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const body = await request.json()
const validatedData = UpdateProfileSchema.parse(body)
// Build update object
const updateData: any = { updatedAt: new Date() }
if (validatedData.name !== undefined) updateData.name = validatedData.name
// Update user profile
const [updatedUser] = await db
.update(user)
.set(updateData)
.where(eq(user.id, userId))
.returning()
if (!updatedUser) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
logger.info(`[${requestId}] User profile updated`, {
userId,
updatedFields: Object.keys(validatedData),
})
return NextResponse.json({
success: true,
user: {
id: updatedUser.id,
name: updatedUser.name,
email: updatedUser.email,
image: updatedUser.image,
},
})
} catch (error: any) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid profile data`, {
errors: error.errors,
})
return NextResponse.json(
{ error: 'Invalid profile data', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Profile update error`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// GET endpoint to fetch current user profile
export async function GET() {
const requestId = crypto.randomUUID().slice(0, 8)
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized profile fetch attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const [userRecord] = await db
.select({
id: user.id,
name: user.name,
email: user.email,
image: user.image,
emailVerified: user.emailVerified,
})
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!userRecord) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
return NextResponse.json({
user: userRecord,
})
} catch (error: any) {
logger.error(`[${requestId}] Profile fetch error`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -206,23 +206,22 @@
}
::-webkit-scrollbar-track {
background-color: hsl(var(--scrollbar-track));
border-radius: var(--radius);
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: hsl(var(--scrollbar-thumb));
background-color: hsl(var(--muted-foreground) / 0.3);
border-radius: var(--radius);
}
::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--scrollbar-thumb-hover));
background-color: hsl(var(--muted-foreground) / 0.3);
}
/* For Firefox */
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--scrollbar-thumb)) hsl(var(--scrollbar-track));
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
}
}

View File

@@ -586,7 +586,7 @@ export function SearchModal({
className='bg-white/50 dark:bg-black/50'
style={{ backdropFilter: 'blur(1.5px)' }}
/>
<DialogPrimitive.Content className='data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 flex h-[580px] w-[700px] translate-x-[-50%] translate-y-[-50%] flex-col gap-0 overflow-hidden rounded-[8px] border border-border bg-background p-0 focus:outline-none focus-visible:outline-none data-[state=closed]:animate-out data-[state=open]:animate-in'>
<DialogPrimitive.Content className='data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 flex h-[580px] w-[700px] translate-x-[-50%] translate-y-[-50%] flex-col gap-0 overflow-hidden rounded-[10px] border border-border bg-background p-0 focus:outline-none focus-visible:outline-none data-[state=closed]:animate-out data-[state=open]:animate-in'>
<VisuallyHidden.Root>
<DialogTitle>Search</DialogTitle>
</VisuallyHidden.Root>

View File

@@ -1,21 +1,14 @@
'use client'
import { useEffect, useState } from 'react'
import { ChevronDown, Lock, LogOut, User, UserPlus } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { AgentIcon } from '@/components/icons'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { signOut, useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { RequestResetForm } from '@/app/(auth)/reset-password/reset-password-form'
import { clearUserData } from '@/stores'
const logger = createLogger('Account')
@@ -24,329 +17,334 @@ interface AccountProps {
onOpenChange: (open: boolean) => void
}
// Mock user data - in a real app, this would come from an auth provider
interface UserData {
isLoggedIn: boolean
name?: string
email?: string
}
interface AccountData {
id: string
name: string
email: string
isActive?: boolean
}
export function Account({ onOpenChange }: AccountProps) {
const router = useRouter()
// In a real app, this would be fetched from an auth provider
const [userData, setUserData] = useState<UserData>({
isLoggedIn: false,
name: '',
email: '',
})
// Get session data using the client hook
const { data: session, isPending, error } = useSession()
const [isLoadingUserData, _setIsLoadingUserData] = useState(false)
// Reset password states
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false)
const [resetPasswordEmail, setResetPasswordEmail] = useState('')
const [isSubmittingResetPassword, setIsSubmittingResetPassword] = useState(false)
const [resetPasswordStatus, setResetPasswordStatus] = useState<{
type: 'success' | 'error' | null
message: string
}>({ type: null, message: '' })
// Form states
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [userImage, setUserImage] = useState<string | null>(null)
// Mock accounts for the multi-account UI
const [accounts, setAccounts] = useState<AccountData[]>([])
const [open, setOpen] = useState(false)
// Loading states
const [isLoadingProfile, setIsLoadingProfile] = useState(false)
const [isUpdatingName, setIsUpdatingName] = useState(false)
// Update user data when session changes
// Edit states
const [isEditingName, setIsEditingName] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
// Reset password state
const [isResettingPassword, setIsResettingPassword] = useState(false)
const [resetPasswordMessage, setResetPasswordMessage] = useState<{
type: 'success' | 'error'
text: string
} | null>(null)
// Fetch user profile on component mount
useEffect(() => {
const updateUserData = async () => {
if (!isPending && session?.user) {
// User is logged in
setUserData({
isLoggedIn: true,
name: session.user.name || 'User',
email: session.user.email,
})
const fetchProfile = async () => {
if (!session?.user) return
setAccounts([
{
id: '1',
name: session.user.name || 'User',
email: session.user.email,
isActive: true,
},
])
setIsLoadingProfile(true)
// Pre-fill the reset password email with the current user's email
setResetPasswordEmail(session.user.email)
} else if (!isPending) {
// User is not logged in
setUserData({
isLoggedIn: false,
name: '',
email: '',
})
setAccounts([])
try {
const response = await fetch('/api/users/me/profile')
if (!response.ok) {
throw new Error('Failed to fetch profile')
}
const data = await response.json()
setName(data.user.name)
setEmail(data.user.email)
setUserImage(data.user.image)
} catch (error) {
logger.error('Error fetching profile:', error)
// Fallback to session data
if (session?.user) {
setName(session.user.name || '')
setEmail(session.user.email || '')
setUserImage(session.user.image || null)
}
} finally {
setIsLoadingProfile(false)
}
}
updateUserData()
}, [session, isPending])
fetchProfile()
}, [session])
const handleSignIn = () => {
// Use Next.js router to navigate to login page
router.push('/login')
setOpen(false)
// Focus input when entering edit mode
useEffect(() => {
if (isEditingName && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [isEditingName])
const handleUpdateName = async () => {
const trimmedName = name.trim()
if (!trimmedName) {
return
}
if (trimmedName === (session?.user?.name || '')) {
setIsEditingName(false)
return
}
setIsUpdatingName(true)
try {
const response = await fetch('/api/users/me/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmedName }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update name')
}
setIsEditingName(false)
} catch (error) {
logger.error('Error updating name:', error)
setName(session?.user?.name || '')
} finally {
setIsUpdatingName(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleUpdateName()
} else if (e.key === 'Escape') {
e.preventDefault()
handleCancelEdit()
}
}
const handleCancelEdit = () => {
setIsEditingName(false)
setName(session?.user?.name || '')
}
const handleInputBlur = () => {
handleUpdateName()
}
const handleSignOut = async () => {
try {
// Start the sign-out process
const signOutPromise = signOut()
// Clear all user data to prevent persistence between accounts
await clearUserData()
// Set a short timeout to improve perceived performance
// while still ensuring auth state starts to clear
setTimeout(() => {
router.push('/login?fromLogout=true')
}, 100)
// Still wait for the promise to resolve/reject to catch errors
await signOutPromise
await Promise.all([signOut(), clearUserData()])
router.push('/login?fromLogout=true')
} catch (error) {
logger.error('Error signing out:', { error })
// Still navigate even if there's an error
router.push('/login?fromLogout=true')
} finally {
setOpen(false)
}
}
const handleResetPassword = async () => {
if (!resetPasswordEmail) {
setResetPasswordStatus({
type: 'error',
message: 'Please enter your email address',
})
return
}
setIsResettingPassword(true)
setResetPasswordMessage(null)
try {
setIsSubmittingResetPassword(true)
setResetPasswordStatus({ type: null, message: '' })
const response = await fetch('/api/auth/forget-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: resetPasswordEmail,
email,
redirectTo: `${window.location.origin}/reset-password`,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || 'Failed to request password reset')
const error = await response.json()
throw new Error(error.message || 'Failed to send reset password email')
}
setResetPasswordStatus({
setResetPasswordMessage({
type: 'success',
message: 'Password reset link sent to your email',
text: 'email sent',
})
// Close dialog after successful submission with a small delay for user to see success message
// Clear success message after 5 seconds
setTimeout(() => {
setResetPasswordDialogOpen(false)
setResetPasswordStatus({ type: null, message: '' })
}, 2000)
setResetPasswordMessage(null)
}, 5000)
} catch (error) {
logger.error('Error requesting password reset:', { error })
setResetPasswordStatus({
logger.error('Error resetting password:', error)
setResetPasswordMessage({
type: 'error',
message: error instanceof Error ? error.message : 'Failed to request password reset',
text: 'error',
})
// Clear error message after 5 seconds
setTimeout(() => {
setResetPasswordMessage(null)
}, 5000)
} finally {
setIsSubmittingResetPassword(false)
setIsResettingPassword(false)
}
}
const activeAccount = accounts.find((acc) => acc.isActive) || accounts[0]
// Loading animation component
const LoadingAccountBlock = () => (
<div className='group flex items-center justify-between gap-3 rounded-lg border bg-card p-4 shadow-sm'>
<div className='flex items-center gap-3'>
<div className='relative flex h-10 w-10 shrink-0 animate-pulse items-center justify-center overflow-hidden rounded-lg bg-muted'>
<div
className='absolute inset-0 animate-[shimmer_1.5s_infinite] bg-gradient-to-r from-transparent via-white/20 to-transparent'
style={{
transform: 'translateX(-100%)',
animation: 'shimmer 1.5s infinite',
}}
/>
</div>
<div className='flex flex-col gap-2'>
<div className='h-4 w-24 animate-pulse rounded bg-muted' />
<div className='h-3 w-32 animate-pulse rounded bg-muted' />
</div>
</div>
<div className='h-4 w-4 rounded bg-muted' />
</div>
)
return (
<div className='space-y-6 p-6'>
<div>
<h3 className='mb-4 font-medium text-lg'>Account</h3>
</div>
<div className='px-6 pt-4 pb-4'>
<div className='flex flex-col gap-4'>
{isLoadingProfile || isPending ? (
<>
{/* User Info Section Skeleton */}
<div className='flex items-center gap-4'>
{/* User Avatar Skeleton */}
<Skeleton className='h-10 w-10 rounded-full' />
{/* Account Dropdown Component */}
<div className='max-w-xs'>
<div className='relative'>
{isPending || isLoadingUserData ? (
<LoadingAccountBlock />
) : (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<div
className={cn(
'group flex cursor-pointer items-center justify-between gap-3 rounded-lg border bg-card p-4 shadow-sm transition-all',
'hover:bg-accent/50 hover:shadow-md',
open && 'bg-accent/50 shadow-md'
)}
data-state={open ? 'open' : 'closed'}
>
<div className='flex items-center gap-3'>
<div className='relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-blue-500'>
{userData.isLoggedIn ? (
<div className='flex h-full w-full items-center justify-center bg-[var(--brand-primary-hover-hex)]'>
<AgentIcon className='-translate-y-[0.5px] text-white transition-transform duration-200 group-hover:scale-110' />
</div>
) : (
<div className='flex h-full w-full items-center justify-center bg-gray-500'>
<AgentIcon className='text-white transition-transform duration-200 group-hover:scale-110' />
</div>
)}
{userData.isLoggedIn && accounts.length > 1 && (
<div className='-bottom-1 -right-1 absolute flex h-5 w-5 items-center justify-center rounded-full bg-primary font-medium text-[10px] text-primary-foreground'>
{accounts.length}
</div>
)}
</div>
<div className='mb-[-2px] flex flex-col gap-1'>
<h3 className='max-w-[200px] truncate font-medium leading-none'>
{userData.isLoggedIn ? activeAccount?.name : 'Sign in'}
</h3>
<p className='max-w-[200px] truncate text-muted-foreground text-sm'>
{userData.isLoggedIn ? activeAccount?.email : 'Click to sign in'}
</p>
</div>
</div>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
open && 'rotate-180'
)}
{/* User Details Skeleton */}
<div className='flex flex-col'>
<Skeleton className='mb-1 h-5 w-32' />
<Skeleton className='h-5 w-48' />
</div>
</div>
{/* Name Field Skeleton */}
<div className='flex flex-col gap-2'>
<Skeleton className='h-4 w-16' />
<div className='flex items-center gap-4'>
<Skeleton className='h-5 w-40' />
<Skeleton className='h-5 w-[42px]' />
</div>
</div>
{/* Email Field Skeleton */}
<div className='flex flex-col gap-2'>
<Skeleton className='h-4 w-16' />
<Skeleton className='h-5 w-48' />
</div>
{/* Password Field Skeleton */}
<div className='flex flex-col gap-2'>
<Skeleton className='h-4 w-16' />
<div className='flex items-center gap-4'>
<Skeleton className='h-5 w-20' />
<Skeleton className='h-5 w-[42px]' />
</div>
</div>
{/* Sign Out Button Skeleton */}
<div>
<Skeleton className='h-8 w-[71px] rounded-[8px]' />
</div>
</>
) : (
<>
{/* User Info Section */}
<div className='flex items-center gap-4'>
{/* User Avatar */}
<div className='relative flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#802FFF]'>
{userImage ? (
<Image
src={userImage}
alt={name || 'User'}
width={40}
height={40}
className='h-full w-full object-cover'
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
className='max-h-[350px] w-[280px] overflow-y-auto'
sideOffset={8}
>
{userData.isLoggedIn ? (
<>
{accounts.length > 1 && (
<>
<div className='mb-2 px-2 py-1.5 font-medium text-muted-foreground text-sm'>
Switch Account
</div>
{accounts.map((account) => (
<DropdownMenuItem
key={account.id}
className={cn(
'flex cursor-pointer items-center gap-2 p-3',
account.isActive && 'bg-accent'
)}
>
<div className='relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[var(--brand-primary-hover-hex)]'>
<User className='h-4 w-4 text-white' />
</div>
<div className='flex flex-col'>
<span className='font-medium leading-none'>{account.name}</span>
<span className='text-muted-foreground text-xs'>{account.email}</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
className='flex cursor-pointer items-center gap-2 py-2.5 pl-3'
onClick={() => {
setResetPasswordDialogOpen(true)
setOpen(false)
}}
>
<Lock className='h-4 w-4' />
<span>Reset Password</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className='flex cursor-pointer items-center gap-2 py-2.5 pl-3 text-destructive focus:text-destructive'
onClick={handleSignOut}
>
<LogOut className='h-4 w-4' />
<span>Sign Out</span>
</DropdownMenuItem>
</>
) : (
<>
<DropdownMenuItem
className='flex cursor-pointer items-center gap-2 py-2.5 pl-3'
onClick={handleSignIn}
>
<UserPlus className='h-4 w-4' />
<span>Sign in</span>
</DropdownMenuItem>
</>
<AgentIcon className='h-5 w-5 text-white' />
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</div>
{/* Reset Password Dialog */}
<Dialog open={resetPasswordDialogOpen} onOpenChange={setResetPasswordDialogOpen}>
<DialogContent className='sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>Reset Password</DialogTitle>
</DialogHeader>
<RequestResetForm
email={resetPasswordEmail}
onEmailChange={setResetPasswordEmail}
onSubmit={handleResetPassword}
isSubmitting={isSubmittingResetPassword}
statusType={resetPasswordStatus.type}
statusMessage={resetPasswordStatus.message}
className='py-4'
/>
</DialogContent>
</Dialog>
{/* User Details */}
<div className='flex flex-col'>
<h3 className='font-medium text-sm'>{name}</h3>
<p className='font-normal text-muted-foreground text-sm'>{email}</p>
</div>
</div>
{/* Name Field */}
<div className='flex flex-col gap-2'>
<Label htmlFor='name' className='font-normal text-muted-foreground text-xs'>
Name
</Label>
{isEditingName ? (
<input
ref={inputRef}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleInputBlur}
className='min-w-0 flex-1 border-0 bg-transparent p-0 text-sm outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
maxLength={100}
disabled={isUpdatingName}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
) : (
<div className='flex items-center gap-4'>
<span className='text-sm'>{name}</span>
<Button
variant='ghost'
className='h-auto p-0 font-normal text-muted-foreground text-xs transition-colors hover:bg-transparent hover:text-foreground'
onClick={() => setIsEditingName(true)}
>
update
<span className='sr-only'>Update name</span>
</Button>
</div>
)}
</div>
{/* Email Field - Read Only */}
<div className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs'>Email</Label>
<p className='text-sm'>{email}</p>
</div>
{/* Password Field */}
<div className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs'>Password</Label>
<div className='flex items-center gap-4'>
<span className='text-sm'></span>
<Button
variant='ghost'
className={`h-auto p-0 font-normal text-xs transition-colors hover:bg-transparent ${
resetPasswordMessage
? resetPasswordMessage.type === 'success'
? 'text-green-500 hover:text-green-600'
: 'text-destructive hover:text-destructive/80'
: 'text-muted-foreground hover:text-foreground'
}`}
onClick={handleResetPassword}
disabled={isResettingPassword}
>
{isResettingPassword
? 'sending...'
: resetPasswordMessage
? resetPasswordMessage.text
: 'reset'}
<span className='sr-only'>Reset password</span>
</Button>
</div>
</div>
{/* Sign Out Button */}
<div>
<Button
onClick={handleSignOut}
variant='destructive'
className='h-8 rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600'
>
Sign Out
</Button>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { Check, Copy, KeySquare, Plus, Trash2 } from 'lucide-react'
import { Check, Copy, Plus, Search } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
@@ -13,15 +13,6 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
@@ -56,6 +47,13 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
const [deleteKey, setDeleteKey] = useState<ApiKey | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [copySuccess, setCopySuccess] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [deleteConfirmationName, setDeleteConfirmationName] = useState('')
// Filter API keys based on search term
const filteredApiKeys = apiKeys.filter((key) =>
key.name.toLowerCase().includes(searchTerm.toLowerCase())
)
// Fetch API keys
const fetchApiKeys = async () => {
@@ -96,10 +94,10 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
// Show the new key dialog with the API key (only shown once)
setNewKey(data.key)
setShowNewKeyDialog(true)
// Reset form
setNewKeyName('')
// Refresh the keys list
fetchApiKeys()
// Close the create dialog
setIsCreating(false)
}
} catch (error) {
logger.error('Error creating API key:', { error })
@@ -154,196 +152,236 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
}
return (
<div className='space-y-6 p-6'>
<div className='flex items-center justify-between'>
<h2 className='font-semibold text-xl'>API Keys</h2>
<Button
onClick={() => setIsCreating(true)}
disabled={isLoading}
size='sm'
className='gap-1.5'
>
<Plus className='h-4 w-4' />
Create Key
</Button>
<div className='relative flex h-full flex-col'>
{/* Fixed Header */}
<div className='px-6 pt-4 pb-2'>
{/* Search Input */}
{isLoading ? (
<Skeleton className='h-9 w-56 rounded-lg' />
) : (
<div className='flex h-9 w-56 items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search API keys...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
)}
</div>
<p className='text-muted-foreground text-sm leading-relaxed'>
API keys allow you to authenticate and trigger workflows. Keep your API keys secure. They
have access to your account and workflows.
</p>
{isLoading ? (
<div className='mt-6 space-y-3'>
<KeySkeleton />
<KeySkeleton />
</div>
) : apiKeys.length === 0 ? (
<div className='mt-6 rounded-md border border-dashed p-8'>
<div className='flex flex-col items-center justify-center text-center'>
<div className='flex h-12 w-12 items-center justify-center rounded-full bg-muted'>
<KeySquare className='h-6 w-6 text-primary' />
{/* Scrollable Content */}
<div className='scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'>
<div className='h-full space-y-2 py-2'>
{isLoading ? (
<div className='space-y-2'>
<ApiKeySkeleton />
<ApiKeySkeleton />
<ApiKeySkeleton />
</div>
<h3 className='mt-4 font-medium text-lg'>No API keys yet</h3>
<p className='mt-2 max-w-sm text-muted-foreground text-sm'>
You don&apos;t have any API keys yet. Create one to get started with the Sim SDK.
</p>
<Button
variant='default'
className='mt-4'
onClick={() => setIsCreating(true)}
size='sm'
>
<Plus className='mr-1.5 h-4 w-4' /> Create API Key
</Button>
</div>
</div>
) : (
<div className='mt-6 space-y-4'>
{apiKeys.map((key) => (
<Card key={key.id} className='p-4 transition-shadow hover:shadow-sm'>
<div className='flex items-center justify-between'>
<div className='space-y-1'>
<h3 className='font-medium text-base'>{key.name}</h3>
<div className='flex items-center space-x-1'>
<p className='text-muted-foreground text-xs'>
Created: {formatDate(key.createdAt)} Last used: {formatDate(key.lastUsed)}
</p>
<div className='rounded bg-muted/50 px-1.5 py-0.5 font-mono text-xs'>
{key.key.slice(-6)}
) : apiKeys.length === 0 ? (
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
Click "Create Key" below to get started
</div>
) : (
<div className='space-y-2'>
{filteredApiKeys.map((key) => (
<div key={key.id} className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs uppercase'>
{key.name}
</Label>
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<div className='flex h-8 items-center rounded-[8px] bg-muted px-3'>
<code className='font-mono text-foreground text-xs'>
{key.key.slice(-6)}
</code>
</div>
<p className='text-muted-foreground text-xs'>
Last used: {formatDate(key.lastUsed)}
</p>
</div>
<Button
variant='ghost'
size='sm'
onClick={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
className='h-8 text-muted-foreground hover:text-foreground'
>
Delete
</Button>
</div>
</div>
<Button
variant='ghost'
size='icon'
onClick={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
}}
className='h-8 w-8 text-destructive hover:bg-destructive/10'
>
<Trash2 className='h-4 w-4' />
<span className='sr-only'>Delete key</span>
</Button>
</div>
</Card>
))}
))}
{/* Show message when search has no results but there are keys */}
{searchTerm.trim() && filteredApiKeys.length === 0 && apiKeys.length > 0 && (
<div className='py-8 text-center text-muted-foreground text-sm'>
No API keys found matching "{searchTerm}"
</div>
)}
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className='bg-background'>
<div className='flex w-full items-center justify-between px-6 py-4'>
{isLoading ? (
<>
<Skeleton className='h-9 w-[117px] rounded-[8px]' />
<div className='w-[108px]' />
</>
) : (
<>
<Button
onClick={() => setIsCreating(true)}
variant='ghost'
className='h-9 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
>
<Plus className='h-4 w-4 stroke-[2px]' />
Create Key
</Button>
<div className='text-muted-foreground text-xs'>Keep your API keys secure</div>
</>
)}
</div>
</div>
{/* Create API Key Dialog */}
<Dialog open={isCreating} onOpenChange={setIsCreating}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>Create new API key</DialogTitle>
<DialogDescription>
Name your API key to help you identify it later. This key will have access to your
account and workflows.
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-3'>
<div className='space-y-2'>
<Label htmlFor='keyName'>API Key Name</Label>
<Input
id='keyName'
placeholder='e.g., Development, Production, etc.'
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className='focus-visible:ring-primary'
/>
</div>
<AlertDialog open={isCreating} onOpenChange={setIsCreating}>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Create new API key</AlertDialogTitle>
<AlertDialogDescription>
This key will have access to your account and workflows. Make sure to copy it after
creation as you won't be able to see it again.
</AlertDialogDescription>
</AlertDialogHeader>
<div className='py-2'>
<p className='mb-2 font-[360] text-sm'>
Enter a name for your API key to help you identify it later.
</p>
<Input
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder='e.g., Development, Production'
className='h-9 rounded-[8px]'
autoFocus
/>
</div>
<DialogFooter className='gap-2 sm:justify-end'>
<Button variant='outline' onClick={() => setIsCreating(false)}>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
onClick={() => setNewKeyName('')}
>
Cancel
</Button>
<Button onClick={handleCreateKey} disabled={!newKeyName.trim()}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
handleCreateKey()
setNewKeyName('')
}}
className='h-9 w-full rounded-[8px] bg-primary text-primary-foreground transition-all duration-200 hover:bg-primary/90'
disabled={!newKeyName.trim()}
>
Create Key
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* New API Key Dialog */}
<Dialog
<AlertDialog
open={showNewKeyDialog}
onOpenChange={(open) => {
setShowNewKeyDialog(open)
if (!open) setNewKey(null)
if (!open) {
setNewKey(null)
setCopySuccess(false)
}
}}
>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>Your API key has been created</DialogTitle>
<DialogDescription>
This is the only time you will see your API key. Copy it now and store it securely.
</DialogDescription>
</DialogHeader>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Your API key has been created</AlertDialogTitle>
<AlertDialogDescription>
This is the only time you will see your API key.{' '}
<span className='font-semibold'>Copy it now and store it securely.</span>
</AlertDialogDescription>
</AlertDialogHeader>
{newKey && (
<div className='space-y-4 py-3'>
<div className='space-y-2'>
<Label>API Key</Label>
<div className='relative'>
<Input
readOnly
value={newKey.key}
className='border-slate-300 bg-muted/50 pr-10 font-mono text-sm'
/>
<Button
variant='ghost'
size='sm'
className='-translate-y-1/2 absolute top-1/2 right-1 h-7 w-7'
onClick={() => copyToClipboard(newKey.key)}
>
{copySuccess ? (
<Check className='h-4 w-4 text-green-500' />
) : (
<Copy className='h-4 w-4' />
)}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
<p className='mt-1 text-muted-foreground text-xs'>
For security, we don&apos;t store the complete key. You won&apos;t be able to view
it again.
</p>
<div className='relative'>
<div className='flex h-9 items-center rounded-[6px] border-none bg-muted px-3 pr-10'>
<code className='flex-1 truncate font-mono text-foreground text-sm'>
{newKey.key}
</code>
</div>
<Button
variant='ghost'
size='icon'
className='-translate-y-1/2 absolute top-1/2 right-1 h-7 w-7 rounded-[4px] text-muted-foreground hover:bg-muted hover:text-foreground'
onClick={() => copyToClipboard(newKey.key)}
>
{copySuccess ? <Check className='h-3.5 w-3.5' /> : <Copy className='h-3.5 w-3.5' />}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
)}
<DialogFooter className='sm:justify-end'>
<Button
onClick={() => {
setShowNewKeyDialog(false)
setNewKey(null)
}}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AlertDialogContent>
</AlertDialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent className='sm:max-w-md'>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Delete API Key</AlertDialogTitle>
<AlertDialogTitle>Delete API key?</AlertDialogTitle>
<AlertDialogDescription>
{deleteKey && (
<>
Are you sure you want to delete the API key{' '}
<span className='font-semibold'>{deleteKey.name}</span>? This action cannot be
undone and any integrations using this key will no longer work.
</>
)}
Deleting this API key will immediately revoke access for any integrations using it.{' '}
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='gap-2 sm:justify-end'>
<AlertDialogCancel onClick={() => setDeleteKey(null)}>Cancel</AlertDialogCancel>
{deleteKey && (
<div className='py-2'>
<p className='mb-2 font-[360] text-sm'>
Enter the API key name <span className='font-semibold'>{deleteKey.name}</span> to
confirm.
</p>
<Input
value={deleteConfirmationName}
onChange={(e) => setDeleteConfirmationName(e.target.value)}
placeholder='Type key name to confirm'
className='h-9 rounded-[8px]'
autoFocus
/>
</div>
)}
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
onClick={() => {
setDeleteKey(null)
setDeleteConfirmationName('')
}}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteKey}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
onClick={() => {
handleDeleteKey()
setDeleteConfirmationName('')
}}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
disabled={!deleteKey || deleteConfirmationName !== deleteKey.name}
>
Delete
</AlertDialogAction>
@@ -354,16 +392,18 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
)
}
function KeySkeleton() {
// Loading skeleton for API keys
function ApiKeySkeleton() {
return (
<Card className='p-4'>
<div className='flex items-center justify-between'>
<div>
<Skeleton className='mb-2 h-5 w-32' />
<Skeleton className='h-4 w-48' />
<div className='flex flex-col gap-2'>
<Skeleton className='h-4 w-32' /> {/* API key name */}
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<Skeleton className='h-8 w-20 rounded-[8px]' /> {/* Key preview */}
<Skeleton className='h-4 w-24' /> {/* Last used */}
</div>
<Skeleton className='h-8 w-8 rounded-md' />
<Skeleton className='h-8 w-16' /> {/* Delete button */}
</div>
</Card>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { Check, Copy, Eye, EyeOff, KeySquare, Plus, Trash2 } from 'lucide-react'
import { Check, Copy, Eye, EyeOff, Plus, Search } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
@@ -10,13 +10,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
Button,
Card,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Skeleton,
@@ -36,8 +29,9 @@ interface CopilotKey {
export function Copilot() {
const [keys, setKeys] = useState<CopilotKey[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [visible, setVisible] = useState<Record<string, boolean>>({})
const [searchTerm, setSearchTerm] = useState('')
// Create flow state
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
@@ -49,13 +43,16 @@ export function Copilot() {
const [deleteKey, setDeleteKey] = useState<CopilotKey | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const hasKeys = keys.length > 0
// Filter keys based on search term
const filteredKeys = keys.filter((key) =>
key.apiKey.toLowerCase().includes(searchTerm.toLowerCase())
)
const maskedValue = useCallback((value: string, show: boolean) => {
if (show) return value
if (!value) return ''
const last6 = value.slice(-6)
return `••••••••••${last6}`
return `•••••${last6}`
}, [])
const fetchKeys = useCallback(async () => {
@@ -134,216 +131,210 @@ export function Copilot() {
}
}
// UI helpers
const isFetching = isLoading && keys.length === 0
return (
<div className='space-y-6 p-6'>
<h2 className='font-semibold text-xl'>Copilot API Keys</h2>
<p className='text-muted-foreground text-sm leading-relaxed'>
Copilot API keys let you authenticate requests to the Copilot endpoints. Keep keys secret
and rotate them regularly.
</p>
<p className='text-muted-foreground text-xs italic'>
For external deployments, set the <span className='font-mono'>COPILOT_API_KEY</span>{' '}
environment variable on that instance to one of the keys generated here.
</p>
{isFetching ? (
<div className='mt-6 space-y-3'>
<Card className='p-4'>
<div className='flex items-center justify-between'>
<div>
<Skeleton className='mb-2 h-5 w-32' />
<Skeleton className='h-4 w-48' />
</div>
<Skeleton className='h-8 w-8 rounded-md' />
</div>
</Card>
<Card className='p-4'>
<div className='flex items-center justify-between'>
<div>
<Skeleton className='mb-2 h-5 w-28' />
<Skeleton className='h-4 w-40' />
</div>
<Skeleton className='h-8 w-8 rounded-md' />
</div>
</Card>
</div>
) : !hasKeys ? (
<div className='mt-6 rounded-md border border-dashed p-8'>
<div className='flex flex-col items-center justify-center text-center'>
<div className='flex h-12 w-12 items-center justify-center rounded-full bg-muted'>
<KeySquare className='h-6 w-6 text-primary' />
</div>
<h3 className='mt-4 font-medium text-lg'>No Copilot keys yet</h3>
<p className='mt-2 max-w-sm text-muted-foreground text-sm'>
Generate a Copilot API key to authenticate requests to the Copilot SDK and methods.
</p>
<Button
variant='default'
className='mt-4'
onClick={onGenerate}
size='sm'
disabled={isLoading}
>
<Plus className='mr-1.5 h-4 w-4' /> Generate Key
</Button>
<div className='relative flex h-full flex-col'>
{/* Fixed Header */}
<div className='px-6 pt-4 pb-2'>
{/* Search Input */}
{isLoading ? (
<Skeleton className='h-9 w-56 rounded-lg' />
) : (
<div className='flex h-9 w-56 items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search API keys...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
</div>
) : (
<div className='mt-6 space-y-4'>
{keys.map((k) => {
const isVisible = !!visible[k.id]
const value = maskedValue(k.apiKey, isVisible)
return (
<Card key={k.id} className='p-4 transition-shadow hover:shadow-sm'>
<div className='flex items-center justify-between gap-4'>
<div className='min-w-0 flex-1'>
<div className='rounded bg-muted/50 px-2 py-1 font-mono text-sm'>{value}</div>
</div>
<div className='flex items-center gap-2'>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='secondary'
size='icon'
onClick={() => setVisible((v) => ({ ...v, [k.id]: !isVisible }))}
className='h-8 w-8'
>
{isVisible ? (
<EyeOff className='h-4 w-4' />
) : (
<Eye className='h-4 w-4' />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{isVisible ? 'Hide' : 'Reveal'}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='secondary'
size='icon'
onClick={() => onCopy(k.apiKey, k.id)}
className='h-8 w-8'
>
{copiedKeyIds[k.id] ? (
<Check className='h-4 w-4 text-green-500' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Copy</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Scrollable Content */}
<div className='scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'>
<div className='h-full space-y-2 py-2'>
{isLoading ? (
<div className='space-y-2'>
<CopilotKeySkeleton />
<CopilotKeySkeleton />
<CopilotKeySkeleton />
</div>
) : keys.length === 0 ? (
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
Click "Generate Key" below to get started
</div>
) : (
<div className='space-y-2'>
{filteredKeys.map((k) => {
const isVisible = !!visible[k.id]
const value = maskedValue(k.apiKey, isVisible)
return (
<div key={k.id} className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs uppercase'>
Copilot API Key
</Label>
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<div className='flex h-8 items-center rounded-[8px] bg-muted px-3'>
<code className='font-mono text-foreground text-xs'>{value}</code>
</div>
<div className='flex items-center gap-2'>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => setVisible((v) => ({ ...v, [k.id]: !isVisible }))}
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
>
{isVisible ? (
<EyeOff className='!h-3.5 !w-3.5' />
) : (
<Eye className='!h-3.5 !w-3.5' />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{isVisible ? 'Hide' : 'Reveal'}</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
setDeleteKey(k)
setShowDeleteDialog(true)
}}
className='h-8 w-8 text-destructive hover:bg-destructive/10'
>
<Trash2 className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => onCopy(k.apiKey, k.id)}
className='h-4 w-4 p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
>
{copiedKeyIds[k.id] ? (
<Check className='!h-3.5 !w-3.5' />
) : (
<Copy className='!h-3.5 !w-3.5' />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Copy</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<Button
variant='ghost'
size='sm'
onClick={() => {
setDeleteKey(k)
setShowDeleteDialog(true)
}}
className='h-8 text-muted-foreground hover:text-foreground'
>
Delete
</Button>
</div>
</div>
)
})}
{/* Show message when search has no results but there are keys */}
{searchTerm.trim() && filteredKeys.length === 0 && keys.length > 0 && (
<div className='py-8 text-center text-muted-foreground text-sm'>
No API keys found matching "{searchTerm}"
</div>
</Card>
)
})}
)}
</div>
)}
</div>
)}
</div>
{/* New Key Dialog */}
<Dialog
{/* Footer */}
<div className='bg-background'>
<div className='flex w-full items-center justify-between px-6 py-4'>
{isLoading ? (
<>
<Skeleton className='h-9 w-[117px] rounded-[8px]' />
<div className='w-[108px]' />
</>
) : (
<>
<Button
onClick={onGenerate}
variant='ghost'
className='h-9 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
disabled={isLoading}
>
<Plus className='h-4 w-4 stroke-[2px]' />
Generate Key
</Button>
<div className='text-muted-foreground text-xs'>Keep your API keys secure</div>
</>
)}
</div>
</div>
{/* New API Key Dialog */}
<AlertDialog
open={showNewKeyDialog}
onOpenChange={(open) => {
setShowNewKeyDialog(open)
if (!open) setNewKey(null)
if (!open) {
setNewKey(null)
setNewKeyCopySuccess(false)
}
}}
>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>Your Copilot API key has been created</DialogTitle>
<DialogDescription>
This is the only time you will see the full key. Copy it now and store it securely.
</DialogDescription>
</DialogHeader>
<AlertDialogContent className='rounded-[10px] sm:max-w-lg'>
<AlertDialogHeader>
<AlertDialogTitle>New Copilot API Key</AlertDialogTitle>
<AlertDialogDescription>
<span className='font-semibold'>Copy it now</span> and store it securely.
</AlertDialogDescription>
</AlertDialogHeader>
{newKey && (
<div className='space-y-4 py-3'>
<div className='space-y-2'>
<Label>API Key</Label>
<div className='relative'>
<Input
readOnly
value={newKey.apiKey}
className='border-slate-300 bg-muted/50 pr-10 font-mono text-sm'
/>
<Button
variant='ghost'
size='sm'
className='-translate-y-1/2 absolute top-1/2 right-1 h-7 w-7'
onClick={() => onCopy(newKey.apiKey)}
>
{newKeyCopySuccess ? (
<Check className='h-4 w-4 text-green-500' />
) : (
<Copy className='h-4 w-4' />
)}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
<p className='mt-1 text-muted-foreground text-xs'>
For security, we don't store the complete key. You won't be able to view it again.
</p>
<div className='relative'>
<div className='flex h-9 items-center rounded-[6px] border-none bg-muted px-3 pr-8'>
<code className='flex-1 truncate font-mono text-foreground text-sm'>
{newKey.apiKey}
</code>
</div>
<Button
variant='ghost'
size='icon'
className='-translate-y-1/2 absolute top-1/2 right-2 h-4 w-4 rounded-[4px] p-0 text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground'
onClick={() => onCopy(newKey.apiKey)}
>
{newKeyCopySuccess ? (
<Check className='!h-3.5 !w-3.5' />
) : (
<Copy className='!h-3.5 !w-3.5' />
)}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
)}
<DialogFooter className='sm:justify-end'>
<Button
onClick={() => {
setShowNewKeyDialog(false)
setNewKey(null)
}}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AlertDialogContent>
</AlertDialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent className='sm:max-w-md'>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Delete Copilot API Key</AlertDialogTitle>
<AlertDialogTitle>Delete Copilot API key?</AlertDialogTitle>
<AlertDialogDescription>
{deleteKey && (
<>
Are you sure you want to delete this Copilot API key? This action cannot be
undone.
</>
)}
Deleting this API key will immediately revoke access for any integrations using it.{' '}
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className='gap-2 sm:justify-end'>
<AlertDialogCancel onClick={() => setDeleteKey(null)}>Cancel</AlertDialogCancel>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
onClick={() => setDeleteKey(null)}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (deleteKey) {
@@ -352,7 +343,7 @@ export function Copilot() {
setShowDeleteDialog(false)
setDeleteKey(null)
}}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
>
Delete
</AlertDialogAction>
@@ -362,3 +353,22 @@ export function Copilot() {
</div>
)
}
// Loading skeleton for Copilot API keys
function CopilotKeySkeleton() {
return (
<div className='flex flex-col gap-2'>
<Skeleton className='h-4 w-32' /> {/* API key label */}
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<Skeleton className='h-8 w-40 rounded-[8px]' /> {/* Key preview */}
<div className='flex items-center gap-2'>
<Skeleton className='h-4 w-4' /> {/* Show/Hide button */}
<Skeleton className='h-4 w-4' /> {/* Copy button */}
</div>
</div>
<Skeleton className='h-8 w-16' /> {/* Delete button */}
</div>
</div>
)
}

View File

@@ -1,11 +1,11 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, ExternalLink, RefreshCw, Search } from 'lucide-react'
import { Check, ChevronDown, ExternalLink, Search } from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { client, useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
@@ -294,192 +294,166 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
}
return (
<div className='space-y-6 p-6'>
<div>
<div className='mb-1 flex items-center justify-between'>
<h3 className='font-medium text-lg'>Credentials</h3>
{/* Search Input */}
<div className='relative w-48'>
<Search className='-translate-y-1/2 absolute top-1/2 left-3 h-4 w-4 text-muted-foreground' />
<Input
placeholder='Search...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='h-9 pl-9 text-sm'
/>
</div>
<div className='relative flex h-full flex-col'>
{/* Search Input */}
<div className='px-6 pt-4 pb-2'>
<div className='flex h-9 w-56 items-center gap-2 rounded-[8px] border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search services...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<p className='mb-6 text-muted-foreground text-sm'>
Connect your accounts to use tools that require authentication.
</p>
</div>
{/* Success message */}
{authSuccess && (
<div className='mb-4 rounded-md border border-green-200 bg-green-50 p-4'>
<div className='flex'>
<div className='flex-shrink-0'>
<Check className='h-5 w-5 text-green-400' />
{/* Scrollable Content */}
<div className='scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'>
<div className='flex flex-col gap-6 pt-2 pb-6'>
{/* Success message */}
{authSuccess && (
<div className='rounded-[8px] border border-green-200 bg-green-50 p-4'>
<div className='flex'>
<div className='flex-shrink-0'>
<Check className='h-5 w-5 text-green-400' />
</div>
<div className='ml-3'>
<p className='font-medium text-green-800 text-sm'>
Account connected successfully!
</p>
</div>
</div>
</div>
<div className='ml-3'>
<p className='font-medium text-green-800 text-sm'>Account connected successfully!</p>
)}
{/* Pending service message - only shown when coming from OAuth required modal */}
{pendingService && showActionRequired && (
<div className='flex items-start gap-3 rounded-[8px] border border-primary/20 bg-primary/5 p-5 text-sm shadow-sm'>
<div className='mt-0.5 min-w-5'>
<ExternalLink className='h-4 w-4 text-primary' />
</div>
<div className='flex flex-1 flex-col'>
<p className='text-muted-foreground'>
<span className='font-medium text-primary'>Action Required:</span> Please connect
your account to enable the requested features. The required service is highlighted
below.
</p>
<Button
variant='outline'
size='sm'
onClick={scrollToHighlightedService}
className='mt-3 flex h-8 items-center gap-1.5 self-start border-primary/20 px-3 font-medium text-primary text-sm transition-colors hover:border-primary hover:bg-primary/10 hover:text-primary'
>
<span>Go to service</span>
<ChevronDown className='h-3.5 w-3.5' />
</Button>
</div>
</div>
</div>
</div>
)}
)}
{/* Pending service message - only shown when coming from OAuth required modal */}
{pendingService && showActionRequired && (
<div className='mb-6 flex items-start gap-3 rounded-md border border-primary/20 bg-primary/5 p-5 text-sm shadow-sm'>
<div className='mt-0.5 min-w-5'>
<ExternalLink className='h-4 w-4 text-primary' />
</div>
<div className='flex flex-1 flex-col'>
<p className='text-muted-foreground'>
<span className='font-medium text-primary'>Action Required:</span> Please connect your
account to enable the requested features. The required service is highlighted below.
</p>
<Button
variant='outline'
size='sm'
onClick={scrollToHighlightedService}
className='mt-3 flex h-8 items-center gap-1.5 self-start border-primary/20 px-3 font-medium text-primary text-sm transition-colors hover:border-primary hover:bg-primary/10 hover:text-primary'
>
<span>Go to service</span>
<ChevronDown className='h-3.5 w-3.5' />
</Button>
</div>
</div>
)}
{/* Loading state */}
{isLoading ? (
<div className='space-y-4'>
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
</div>
) : (
<div className='space-y-6'>
{/* Group services by provider */}
{Object.entries(filteredGroupedServices).map(([providerKey, providerServices]) => (
<div key={providerKey} className='space-y-4'>
<h4 className='font-medium text-muted-foreground text-sm'>
{OAUTH_PROVIDERS[providerKey]?.name || 'Other Services'}
</h4>
<div className='space-y-4'>
{providerServices.map((service) => (
<Card
key={service.id}
className={cn(
'p-6 transition-all hover:shadow-md',
pendingService === service.id && 'border-primary shadow-md'
)}
ref={pendingService === service.id ? pendingServiceRef : undefined}
>
<div className='flex w-full items-start gap-4'>
<div className='flex w-full items-start gap-4'>
<div className='flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-muted'>
{/* Loading state */}
{isLoading ? (
<div className='flex flex-col gap-6'>
{/* Google section - 5 blocks */}
<div className='flex flex-col gap-2'>
<Skeleton className='h-4 w-16' /> {/* "GOOGLE" label */}
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
</div>
{/* Microsoft section - 6 blocks */}
<div className='flex flex-col gap-2'>
<Skeleton className='h-4 w-20' /> {/* "MICROSOFT" label */}
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
<ConnectionSkeleton />
</div>
</div>
) : (
<div className='flex flex-col gap-6'>
{/* Services list */}
{Object.entries(filteredGroupedServices).map(([providerKey, providerServices]) => (
<div key={providerKey} className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs uppercase'>
{OAUTH_PROVIDERS[providerKey]?.name || 'Other Services'}
</Label>
{providerServices.map((service) => (
<div
key={service.id}
className={cn(
'flex items-center justify-between gap-4',
pendingService === service.id && '-m-2 rounded-[8px] bg-primary/5 p-2'
)}
ref={pendingService === service.id ? pendingServiceRef : undefined}
>
<div className='flex items-center gap-3'>
<div className='flex h-10 w-10 shrink-0 items-center justify-center rounded-[8px] bg-muted'>
{typeof service.icon === 'function'
? service.icon({ className: 'h-5 w-5' })
: service.icon}
</div>
<div className='w-full space-y-1'>
<div>
<h4 className='font-medium leading-none'>{service.name}</h4>
<p className='mt-1 text-muted-foreground text-sm'>
<div className='min-w-0'>
<div className='flex items-center gap-2'>
<span className='font-normal text-sm'>{service.name}</span>
</div>
{service.accounts && service.accounts.length > 0 ? (
<p className='truncate text-muted-foreground text-xs'>
{service.accounts.map((a) => a.name).join(', ')}
</p>
) : (
<p className='truncate text-muted-foreground text-xs'>
{service.description}
</p>
</div>
{service.accounts && service.accounts.length > 0 && (
<div className='w-full space-y-2 pt-3'>
{service.accounts.map((account) => (
<div
key={account.id}
className='flex w-full items-center justify-between gap-2 rounded-md border bg-card/50 p-2'
>
<div className='flex items-center gap-2'>
<div className='flex h-6 w-6 items-center justify-center rounded-full bg-green-500/10'>
<Check className='h-3 w-3 text-green-600' />
</div>
<span className='font-medium text-sm'>{account.name}</span>
</div>
<Button
variant='ghost'
size='sm'
onClick={() => handleDisconnect(service, account.id)}
disabled={isConnecting === `${service.id}-${account.id}`}
className='h-7 px-2'
>
{isConnecting === `${service.id}-${account.id}` ? (
<RefreshCw className='h-3 w-3 animate-spin' />
) : (
'Disconnect'
)}
</Button>
</div>
))}
{/* <Button
variant="outline"
size="sm"
className="w-full mt-2"
onClick={() => handleConnect(service)}
disabled={isConnecting === service.id}
>
{isConnecting === service.id ? (
<>
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
Connecting...
</>
) : (
<>
<Plus className="h-3 w-3 mr-2" />
Connect Another Account
</>
)}
</Button> */}
</div>
)}
</div>
</div>
{!service.accounts?.length && (
<div className='ml-auto flex justify-end'>
<Button
variant='default'
size='sm'
onClick={() => handleConnect(service)}
disabled={isConnecting === service.id}
className='shrink-0'
>
{isConnecting === service.id ? (
<>
<RefreshCw className='mr-2 h-4 w-4 animate-spin' />
Connecting...
</>
) : (
'Connect'
)}
</Button>
</div>
{service.accounts && service.accounts.length > 0 ? (
<Button
variant='ghost'
size='sm'
onClick={() => handleDisconnect(service, service.accounts![0].id)}
disabled={isConnecting === `${service.id}-${service.accounts![0].id}`}
className={cn(
'h-8 text-muted-foreground hover:text-foreground',
isConnecting === `${service.id}-${service.accounts![0].id}` &&
'cursor-not-allowed'
)}
>
Disconnect
</Button>
) : (
<Button
variant='outline'
size='sm'
onClick={() => handleConnect(service)}
disabled={isConnecting === service.id}
className={cn('h-8', isConnecting === service.id && 'cursor-not-allowed')}
>
Connect
</Button>
)}
</div>
</Card>
))}
</div>
</div>
))}
))}
</div>
))}
{/* Show message when search has no results */}
{searchTerm.trim() && Object.keys(filteredGroupedServices).length === 0 && (
<div className='py-8 text-center text-muted-foreground text-sm'>
No services found matching "{searchTerm}"
{/* Show message when search has no results */}
{searchTerm.trim() && Object.keys(filteredGroupedServices).length === 0 && (
<div className='py-8 text-center text-muted-foreground text-sm'>
No services found matching "{searchTerm}"
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
)
}
@@ -487,17 +461,15 @@ export function Credentials({ onOpenChange }: CredentialsProps) {
// Loading skeleton for connections
function ConnectionSkeleton() {
return (
<Card className='p-6'>
<div className='flex items-start justify-between gap-4'>
<div className='flex items-start gap-4'>
<Skeleton className='h-12 w-12 rounded-lg' />
<div className='space-y-2'>
<Skeleton className='h-5 w-32' />
<Skeleton className='h-4 w-48' />
</div>
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<Skeleton className='h-10 w-10 rounded-[8px]' />
<div className='space-y-1'>
<Skeleton className='h-5 w-24' />
<Skeleton className='h-4 w-32' />
</div>
<Skeleton className='h-9 w-24 shrink-0' />
</div>
</Card>
<Skeleton className='h-8 w-20' />
</div>
)
}

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Search } from 'lucide-react'
import { Plus, Search } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
@@ -14,7 +14,7 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { useEnvironmentStore } from '@/stores/settings/environment/store'
import type { EnvironmentVariable as StoreEnvironmentVariable } from '@/stores/settings/environment/types'
@@ -28,15 +28,20 @@ interface UIEnvironmentVariable extends StoreEnvironmentVariable {
interface EnvironmentVariablesProps {
onOpenChange: (open: boolean) => void
registerCloseHandler?: (handler: (open: boolean) => void) => void
}
export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps) {
const { variables } = useEnvironmentStore()
export function EnvironmentVariables({
onOpenChange,
registerCloseHandler,
}: EnvironmentVariablesProps) {
const { variables, isLoading } = useEnvironmentStore()
const [envVars, setEnvVars] = useState<UIEnvironmentVariable[]>([])
const [searchTerm, setSearchTerm] = useState('')
const [focusedValueIndex, setFocusedValueIndex] = useState<number | null>(null)
const [showUnsavedChanges, setShowUnsavedChanges] = useState(false)
const [shouldScrollToBottom, setShouldScrollToBottom] = useState(false)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const pendingClose = useRef(false)
@@ -75,6 +80,16 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
return false
}, [envVars])
// Intercept close attempts to check for unsaved changes
const handleModalClose = (open: boolean) => {
if (!open && hasChanges) {
setShowUnsavedChanges(true)
pendingClose.current = true
} else {
onOpenChange(open)
}
}
// Initialization effect
useEffect(() => {
const existingVars = Object.values(variables)
@@ -84,15 +99,23 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
pendingClose.current = false
}, [variables])
// Scroll effect
// Register close handler with parent
useEffect(() => {
if (scrollContainerRef.current) {
if (registerCloseHandler) {
registerCloseHandler(handleModalClose)
}
}, [registerCloseHandler, hasChanges])
// Scroll effect - only when explicitly adding a new variable
useEffect(() => {
if (shouldScrollToBottom && scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
top: scrollContainerRef.current.scrollHeight,
behavior: 'smooth',
})
setShouldScrollToBottom(false)
}
}, [envVars.length])
}, [shouldScrollToBottom])
// Variable management functions
const addEnvVar = () => {
@@ -100,6 +123,8 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
setEnvVars([...envVars, newVar])
// Clear search to ensure the new variable is visible
setSearchTerm('')
// Trigger scroll to bottom
setShouldScrollToBottom(true)
}
const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => {
@@ -168,18 +193,12 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
if (parsedVars.length > 0) {
const existingVars = envVars.filter((v) => v.key || v.value)
setEnvVars([...existingVars, ...parsedVars])
// Scroll to bottom when pasting multiple variables
setShouldScrollToBottom(true)
}
}
// Dialog management
const handleClose = () => {
if (hasChanges) {
setShowUnsavedChanges(true)
pendingClose.current = true
} else {
onOpenChange(false)
}
}
const handleCancel = () => {
setEnvVars(JSON.parse(JSON.stringify(initialVarsRef.current)))
@@ -227,6 +246,7 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
autoCapitalize='off'
spellCheck='false'
name={`env-var-key-${envVar.id || originalIndex}-${Math.random()}`}
className='h-9 rounded-[8px] border-none bg-muted px-3 font-normal text-sm ring-0 ring-offset-0 placeholder:text-muted-foreground focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<Input
data-input-type='value'
@@ -238,7 +258,7 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
onBlur={() => setFocusedValueIndex(null)}
onPaste={(e) => handlePaste(e, originalIndex)}
placeholder='Enter value'
className='allow-scroll'
className='allow-scroll h-9 rounded-[8px] border-none bg-muted px-3 font-normal text-sm ring-0 ring-offset-0 placeholder:text-muted-foreground focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
@@ -249,7 +269,7 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
variant='ghost'
size='icon'
onClick={() => removeEnvVar(originalIndex)}
className='h-10 w-10'
className='h-9 w-9 rounded-[8px] bg-muted p-0 text-muted-foreground hover:bg-muted/70'
>
×
</Button>
@@ -257,64 +277,82 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
)
return (
<div className='flex h-full flex-col'>
<div className='relative flex h-full flex-col'>
{/* Fixed Header */}
<div className='px-6 pt-6'>
<div className='mb-6 flex items-center justify-between'>
<h2 className='font-medium text-lg'>Environment Variables</h2>
{/* Search Input */}
<div className='relative w-48'>
<Search className='-translate-y-1/2 absolute top-1/2 left-3 h-4 w-4 text-muted-foreground' />
<div className='px-6 pt-4 pb-2'>
{/* Search Input */}
{isLoading ? (
<Skeleton className='h-9 w-56 rounded-[8px]' />
) : (
<div className='flex h-9 w-56 items-center gap-2 rounded-[8px] border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search...'
placeholder='Search variables...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='h-9 pl-9 text-sm'
className='flex-1 border-0 bg-transparent px-0 font-[380] font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
</div>
<div className={`${GRID_COLS} mb-2 px-0.5`}>
<Label>Key</Label>
<Label>Value</Label>
<div />
</div>
)}
</div>
{/* Scrollable Content */}
<div
ref={scrollContainerRef}
className='scrollbar-thin scrollbar-thumb-muted-foreground/20 hover:scrollbar-thumb-muted-foreground/25 scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'
className='scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'
>
<div className='space-y-2 py-2'>
{filteredEnvVars.map(({ envVar, originalIndex }) =>
renderEnvVarRow(envVar, originalIndex)
)}
{/* Show message when search has no results but there are variables */}
{searchTerm.trim() && filteredEnvVars.length === 0 && envVars.length > 0 && (
<div className='py-8 text-center text-muted-foreground text-sm'>
No environment variables found matching "{searchTerm}"
</div>
<div className='h-full space-y-2 py-2'>
{isLoading ? (
<>
{/* Show 3 skeleton rows */}
{[1, 2, 3].map((index) => (
<div key={index} className={`${GRID_COLS} items-center`}>
<Skeleton className='h-9 rounded-[8px]' />
<Skeleton className='h-9 rounded-[8px]' />
<Skeleton className='h-9 w-9 rounded-[8px]' />
</div>
))}
</>
) : (
<>
{filteredEnvVars.map(({ envVar, originalIndex }) =>
renderEnvVarRow(envVar, originalIndex)
)}
{/* Show message when search has no results but there are variables */}
{searchTerm.trim() && filteredEnvVars.length === 0 && envVars.length > 0 && (
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
No environment variables found matching "{searchTerm}"
</div>
)}
</>
)}
</div>
</div>
{/* Fixed Footer */}
<div className='mt-auto border-t px-6 pt-4 pb-6'>
<div className='flex flex-col gap-4'>
<Button variant='outline' size='sm' onClick={addEnvVar}>
Add Variable
</Button>
{/* Footer */}
<div className='bg-background'>
<div className='flex w-full items-center justify-between px-6 py-4'>
{isLoading ? (
<>
<Skeleton className='h-9 w-[117px] rounded-[8px]' />
<Skeleton className='h-9 w-[108px] rounded-[8px]' />
</>
) : (
<>
<Button
onClick={addEnvVar}
variant='ghost'
className='h-9 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
>
<Plus className='h-4 w-4 stroke-[2px]' />
Add Variable
</Button>
<div className='flex justify-end space-x-2'>
<Button variant='outline' onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!hasChanges}>
Save Changes
</Button>
</div>
<Button onClick={handleSave} disabled={!hasChanges} className='h-9 rounded-[8px]'>
Save Changes
</Button>
</>
)}
</div>
</div>
@@ -326,9 +364,16 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps
You have unsaved changes. Do you want to save them before closing?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancel}>Discard Changes</AlertDialogCancel>
<AlertDialogAction onClick={handleSave}>Save Changes</AlertDialogAction>
<AlertDialogFooter className='flex'>
<AlertDialogCancel onClick={handleCancel} className='h-9 w-full rounded-[8px]'>
Discard Changes
</AlertDialogCancel>
<AlertDialogAction
onClick={handleSave}
className='h-9 w-full rounded-[8px] transition-all duration-200'
>
Save Changes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View File

@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react'
import { AlertTriangle, Info } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { useEffect } from 'react'
import { Info } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
@@ -23,10 +22,7 @@ const TOOLTIPS = {
}
export function General() {
const [retryCount, setRetryCount] = useState(0)
const isLoading = useGeneralStore((state) => state.isLoading)
const error = useGeneralStore((state) => state.error)
const theme = useGeneralStore((state) => state.theme)
const isAutoConnectEnabled = useGeneralStore((state) => state.isAutoConnectEnabled)
@@ -53,10 +49,10 @@ export function General() {
useEffect(() => {
const loadData = async () => {
await loadSettings(retryCount > 0)
await loadSettings()
}
loadData()
}, [loadSettings, retryCount])
}, [loadSettings])
const handleThemeChange = async (value: 'system' | 'light' | 'dark') => {
await setTheme(value)
@@ -80,129 +76,193 @@ export function General() {
}
}
const handleRetry = () => {
setRetryCount((prev) => prev + 1)
}
return (
<div className='space-y-6 p-6'>
{error && (
<Alert variant='destructive' className='mb-4'>
<AlertTriangle className='h-4 w-4' />
<AlertDescription className='flex items-center justify-between'>
<span>Failed to load settings: {error}</span>
<Button variant='outline' size='sm' onClick={handleRetry} disabled={isLoading}>
Retry
</Button>
</AlertDescription>
</Alert>
)}
<div className='px-6 pt-4 pb-2'>
<div className='flex flex-col gap-4'>
{isLoading ? (
<>
{/* Theme setting with skeleton value */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label htmlFor='theme-select' className='font-normal'>
Theme
</Label>
</div>
<Skeleton className='h-9 w-[180px]' />
</div>
<div>
<h2 className='mb-[22px] font-medium text-lg'>General Settings</h2>
<div className='space-y-4'>
{isLoading ? (
<>
<SettingRowSkeleton />
<SettingRowSkeleton />
<SettingRowSkeleton />
<SettingRowSkeleton />
</>
) : (
<>
<div className='flex items-center justify-between py-1'>
<div className='flex items-center gap-2'>
<Label htmlFor='theme-select' className='font-medium'>
Theme
</Label>
</div>
<Select
value={theme}
onValueChange={handleThemeChange}
disabled={isLoading || isThemeLoading}
>
<SelectTrigger id='theme-select' className='w-[180px]'>
<SelectValue placeholder='Select theme' />
</SelectTrigger>
<SelectContent>
<SelectItem value='system'>System</SelectItem>
<SelectItem value='light'>Light</SelectItem>
<SelectItem value='dark'>Dark</SelectItem>
</SelectContent>
</Select>
</div>
<div className='flex items-center justify-between py-1'>
<div className='flex items-center gap-2'>
<Label htmlFor='auto-connect' className='font-medium'>
Auto-connect on drop
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-7 p-1 text-gray-500'
aria-label='Learn more about auto-connect feature'
disabled={isLoading || isAutoConnectLoading}
>
<Info className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.autoConnect}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id='auto-connect'
checked={isAutoConnectEnabled}
onCheckedChange={handleAutoConnectChange}
disabled={isLoading || isAutoConnectLoading}
/>
{/* Auto-connect setting with skeleton value */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label htmlFor='auto-connect' className='font-normal'>
Auto-connect on drop
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-7 p-1 text-gray-500'
aria-label='Learn more about auto-connect feature'
disabled={true}
>
<Info className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.autoConnect}</p>
</TooltipContent>
</Tooltip>
</div>
<Skeleton className='h-6 w-11 rounded-full' />
</div>
<div className='flex items-center justify-between py-1'>
<div className='flex items-center gap-2'>
<Label htmlFor='console-expanded-by-default' className='font-medium'>
Console expanded by default
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-7 p-1 text-gray-500'
aria-label='Learn more about console expanded by default'
disabled={isLoading || isConsoleExpandedByDefaultLoading}
>
<Info className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.consoleExpandedByDefault}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id='console-expanded-by-default'
checked={isConsoleExpandedByDefault}
onCheckedChange={handleConsoleExpandedByDefaultChange}
disabled={isLoading || isConsoleExpandedByDefaultLoading}
/>
{/* Console expanded setting with skeleton value */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label htmlFor='console-expanded-by-default' className='font-normal'>
Console expanded by default
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-7 p-1 text-gray-500'
aria-label='Learn more about console expanded by default'
disabled={true}
>
<Info className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.consoleExpandedByDefault}</p>
</TooltipContent>
</Tooltip>
</div>
</>
)}
</div>
<Skeleton className='h-6 w-11 rounded-full' />
</div>
</>
) : (
<>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label htmlFor='theme-select' className='font-normal'>
Theme
</Label>
</div>
<Select
value={theme}
onValueChange={handleThemeChange}
disabled={isLoading || isThemeLoading}
>
<SelectTrigger id='theme-select' className='h-9 w-[180px]'>
<SelectValue placeholder='Select theme' />
</SelectTrigger>
<SelectContent className='min-w-32 rounded-[10px] border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[#202020]'>
<SelectItem
value='system'
className='rounded-[8px] text-card-foreground text-sm hover:bg-muted focus:bg-muted'
>
System
</SelectItem>
<SelectItem
value='light'
className='rounded-[8px] text-card-foreground text-sm hover:bg-muted focus:bg-muted'
>
Light
</SelectItem>
<SelectItem
value='dark'
className='rounded-[8px] text-card-foreground text-sm hover:bg-muted focus:bg-muted'
>
Dark
</SelectItem>
</SelectContent>
</Select>
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label htmlFor='auto-connect' className='font-normal'>
Auto-connect on drop
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-7 p-1 text-gray-500'
aria-label='Learn more about auto-connect feature'
disabled={isLoading || isAutoConnectLoading}
>
<Info className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.autoConnect}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id='auto-connect'
checked={isAutoConnectEnabled}
onCheckedChange={handleAutoConnectChange}
disabled={isLoading || isAutoConnectLoading}
/>
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label htmlFor='console-expanded-by-default' className='font-normal'>
Console expanded by default
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-7 p-1 text-gray-500'
aria-label='Learn more about console expanded by default'
disabled={isLoading || isConsoleExpandedByDefaultLoading}
>
<Info className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.consoleExpandedByDefault}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id='console-expanded-by-default'
checked={isConsoleExpandedByDefault}
onCheckedChange={handleConsoleExpandedByDefaultChange}
disabled={isLoading || isConsoleExpandedByDefaultLoading}
/>
</div>
</>
)}
</div>
</div>
)
}
const SettingRowSkeleton = () => (
<div className='flex items-center justify-between py-1'>
const SettingRowSkeleton = ({
hasInfoButton = false,
isSwitch = false,
}: {
hasInfoButton?: boolean
isSwitch?: boolean
}) => (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Skeleton className='h-5 w-32' />
{hasInfoButton && <Skeleton className='h-5 w-5 rounded' />}
</div>
<Skeleton className='h-6 w-12' />
{isSwitch ? (
<Skeleton className='h-6 w-11 rounded-full' />
) : (
<Skeleton className='h-9 w-[180px]' />
)}
</div>
)

View File

@@ -45,61 +45,69 @@ export function Privacy() {
}
return (
<div className='space-y-6 p-6'>
<div>
<h2 className='mb-[22px] font-medium text-lg'>Privacy Settings</h2>
<div className='space-y-4'>
{isLoading ? (
<SettingRowSkeleton />
) : (
<div className='flex items-center justify-between py-1'>
<div className='flex items-center gap-2'>
<Label htmlFor='telemetry' className='font-medium'>
Allow anonymous telemetry
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-7 p-1 text-gray-500'
aria-label='Learn more about telemetry data collection'
>
<Info className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.telemetry}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id='telemetry'
checked={telemetryEnabled}
onCheckedChange={handleTelemetryToggle}
disabled={isLoading}
/>
<div className='px-6 pt-4 pb-2'>
<div className='flex flex-col gap-2'>
{isLoading ? (
<SettingRowSkeleton hasInfoButton isSwitch />
) : (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label htmlFor='telemetry' className='font-normal'>
Allow anonymous telemetry
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-7 p-1 text-gray-500'
aria-label='Learn more about telemetry data collection'
>
<Info className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.telemetry}</p>
</TooltipContent>
</Tooltip>
</div>
)}
</div>
</div>
<Switch
id='telemetry'
checked={telemetryEnabled}
onCheckedChange={handleTelemetryToggle}
disabled={isLoading}
/>
</div>
)}
<div className='border-t pt-4'>
<p className='text-muted-foreground text-xs'>
We use OpenTelemetry to collect anonymous usage data to improve Sim. All data is collected
in accordance with our privacy policy, and you can opt-out at any time. This setting
applies to your account on all devices.
</p>
<div className='border-t pt-4'>
<p className='text-muted-foreground text-xs'>
We use OpenTelemetry to collect anonymous usage data to improve Sim. All data is
collected in accordance with our privacy policy, and you can opt-out at any time. This
setting applies to your account on all devices.
</p>
</div>
</div>
</div>
)
}
const SettingRowSkeleton = () => (
<div className='flex items-center justify-between py-1'>
const SettingRowSkeleton = ({
hasInfoButton = false,
isSwitch = false,
}: {
hasInfoButton?: boolean
isSwitch?: boolean
}) => (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Skeleton className='h-5 w-48' />
<Skeleton className='h-5 w-32' />
{hasInfoButton && <Skeleton className='h-7 w-7 rounded' />}
</div>
<Skeleton className='h-6 w-12' />
{isSwitch ? (
<Skeleton className='h-6 w-11 rounded-full' />
) : (
<Skeleton className='h-9 w-[180px]' />
)}
</div>
)

View File

@@ -1,13 +1,13 @@
import {
Bot,
CreditCard,
KeyRound,
KeySquare,
Lock,
FileCode,
Key,
Settings,
Shield,
UserCircle,
User,
Users,
Waypoints,
} from 'lucide-react'
import { getEnv, isTruthy } from '@/lib/env'
import { isHosted } from '@/lib/environment'
@@ -56,29 +56,29 @@ const allNavigationItems: NavigationItem[] = [
label: 'General',
icon: Settings,
},
{
id: 'credentials',
label: 'Integrations',
icon: Waypoints,
},
{
id: 'environment',
label: 'Environment',
icon: KeyRound,
icon: FileCode,
},
{
id: 'account',
label: 'Account',
icon: UserCircle,
},
{
id: 'credentials',
label: 'Credentials',
icon: Lock,
icon: User,
},
{
id: 'apikeys',
label: 'API Keys',
icon: KeySquare,
icon: Key,
},
{
id: 'copilot',
label: 'Copilot',
label: 'Copilot Keys',
icon: Bot,
},
{
@@ -126,22 +126,36 @@ export function SettingsNavigation({
})
return (
<div className='py-4'>
<div className='px-2 py-4'>
{navigationItems.map((item) => (
<button
key={item.id}
onClick={() => onSectionChange(item.id)}
className={cn(
'flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors',
'hover:bg-muted/50',
activeSection === item.id
? 'bg-muted/50 font-medium text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
<item.icon className='h-4 w-4' />
<span>{item.label}</span>
</button>
<div key={item.id} className='mb-1'>
<button
onClick={() => onSectionChange(item.id)}
className={cn(
'group flex h-9 w-full cursor-pointer items-center rounded-[8px] px-2 py-2 font-medium font-sans text-sm transition-colors',
activeSection === item.id ? 'bg-muted' : 'hover:bg-muted'
)}
>
<item.icon
className={cn(
'mr-2 h-[14px] w-[14px] flex-shrink-0 transition-colors',
activeSection === item.id
? 'text-foreground'
: 'text-muted-foreground group-hover:text-foreground'
)}
/>
<span
className={cn(
'min-w-0 flex-1 select-none truncate pr-1 text-left transition-colors',
activeSection === item.id
? 'text-foreground'
: 'text-muted-foreground group-hover:text-foreground'
)}
>
{item.label}
</span>
</button>
</div>
))}
</div>
)

View File

@@ -1,144 +0,0 @@
import { useEffect, useState } from 'react'
import { AlertCircle } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { useActiveOrganization, useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('BillingSummary')
interface BillingSummaryData {
type: 'individual' | 'organization'
plan: string
currentUsage: number
planMinimum: number
projectedCharge: number
usageLimit: number
percentUsed: number
isWarning: boolean
isExceeded: boolean
daysRemaining: number
organizationData?: {
seatCount: number
averageUsagePerSeat: number
totalMinimum: number
}
}
interface BillingSummaryProps {
showDetails?: boolean
className?: string
onDataLoaded?: (data: BillingSummaryData) => void
onError?: (error: string) => void
}
export function BillingSummary({
showDetails = true,
className = '',
onDataLoaded,
onError,
}: BillingSummaryProps) {
const { data: session } = useSession()
const { data: activeOrg } = useActiveOrganization()
const [billingSummary, setBillingSummary] = useState<BillingSummaryData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
async function loadBillingSummary() {
if (!session?.user?.id) return
try {
setIsLoading(true)
const url = new URL('/api/billing', window.location.origin)
if (activeOrg?.id) {
url.searchParams.set('context', 'organization')
url.searchParams.set('id', activeOrg.id)
} else {
url.searchParams.set('context', 'user')
}
const response = await fetch(url.toString())
if (!response.ok) {
throw new Error(`Failed to fetch billing summary: ${response.statusText}`)
}
const result = await response.json()
if (!result.success) {
throw new Error(result.error || 'Failed to load billing data')
}
setBillingSummary(result.data)
setError(null)
onDataLoaded?.(result.data)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to load billing data'
setError(errorMessage)
onError?.(errorMessage)
logger.error('Failed to load billing summary', { error: err })
} finally {
setIsLoading(false)
}
}
loadBillingSummary()
}, [session?.user?.id, activeOrg?.id, onDataLoaded, onError])
const getStatusBadge = () => {
if (!billingSummary) return null
if (billingSummary.isExceeded) {
return (
<Badge variant='destructive' className='gap-1'>
<AlertCircle className='h-3 w-3' />
Limit Exceeded
</Badge>
)
}
if (billingSummary.isWarning) {
return (
<Badge variant='outline' className='gap-1 border-yellow-500 text-yellow-700'>
<AlertCircle className='h-3 w-3' />
Approaching Limit
</Badge>
)
}
return null
}
const formatCurrency = (amount: number) => `$${amount.toFixed(2)}`
if (isLoading || error || !billingSummary) {
return null
}
return (
<div className={className}>
{/* Status Badge */}
{getStatusBadge()}
{/* Billing Details */}
{showDetails && (
<div className='mt-3 space-y-1 text-muted-foreground text-xs'>
<div className='flex justify-between'>
<span>Plan minimum:</span>
<span>{formatCurrency(billingSummary.planMinimum)}</span>
</div>
<div className='flex justify-between'>
<span>Projected charge:</span>
<span className='font-medium'>{formatCurrency(billingSummary.projectedCharge)}</span>
</div>
{billingSummary.organizationData && (
<div className='flex justify-between'>
<span>Team seats:</span>
<span>{billingSummary.organizationData.seatCount}</span>
</div>
)}
</div>
)}
</div>
)
}
export type { BillingSummaryData }

View File

@@ -1,16 +1,20 @@
import { useState } from 'react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
'use client'
import { useEffect, useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useSession, useSubscription } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useOrganizationStore } from '@/stores/organization'
import { useSubscriptionStore } from '@/stores/subscription/store'
@@ -37,6 +41,16 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
const { activeOrganization } = useOrganizationStore()
const { getSubscriptionStatus } = useSubscriptionStore()
// Clear error after 3 seconds
useEffect(() => {
if (error) {
const timer = setTimeout(() => {
setError(null)
}, 3000)
return () => clearTimeout(timer)
}
}, [error])
// Don't show for free plans
if (!subscription.isPaid) {
return null
@@ -115,44 +129,41 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
return (
<>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<div>
<span className='font-medium text-sm'>Cancel Subscription</span>
<p className='mt-1 text-muted-foreground text-xs'>
You'll keep access until {formatDate(periodEndDate)}
</p>
</div>
<Button
variant='destructive'
size='sm'
onClick={() => setIsDialogOpen(true)}
disabled={isLoading}
>
Cancel
</Button>
<div className='flex items-center justify-between'>
<div>
<span className='font-medium text-sm'>Manage Subscription</span>
<p className='mt-1 text-muted-foreground text-xs'>
You'll keep access until {formatDate(periodEndDate)}
</p>
</div>
{error && (
<Alert variant='destructive'>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
variant='outline'
onClick={() => setIsDialogOpen(true)}
disabled={isLoading}
className={cn(
'h-8 rounded-[8px] font-medium text-xs transition-all duration-200',
error
? 'border-red-500 text-red-500 dark:border-red-500 dark:text-red-500'
: 'text-muted-foreground hover:border-red-500 hover:bg-red-500 hover:text-white dark:hover:border-red-500 dark:hover:bg-red-500'
)}
>
{error ? 'Error' : 'Manage'}
</Button>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Cancel {subscription.plan} subscription?</DialogTitle>
<DialogDescription>
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel {subscription.plan} subscription?</AlertDialogTitle>
<AlertDialogDescription>
You'll be redirected to Stripe to manage your subscription. You'll keep access until{' '}
{formatDate(periodEndDate)}, then downgrade to free plan.
</DialogDescription>
</DialogHeader>
</AlertDialogDescription>
</AlertDialogHeader>
<div className='space-y-3'>
<div className='rounded-lg bg-muted p-3 text-sm'>
<ul className='space-y-1 text-muted-foreground'>
<div className='py-2'>
<div className='rounded-[8px] bg-muted/50 p-3 text-sm'>
<ul className='space-y-1 text-muted-foreground text-xs'>
<li> Keep all features until {formatDate(periodEndDate)}</li>
<li> No more charges</li>
<li> Data preserved</li>
@@ -161,16 +172,24 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => setIsDialogOpen(false)} disabled={isLoading}>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
onClick={() => setIsDialogOpen(false)}
disabled={isLoading}
>
Keep Subscription
</Button>
<Button variant='destructive' onClick={handleCancel} disabled={isLoading}>
</AlertDialogCancel>
<AlertDialogAction
onClick={handleCancel}
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
disabled={isLoading}
>
{isLoading ? 'Redirecting...' : 'Continue'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1 @@
export { CancelSubscription } from './cancel-subscription'

View File

@@ -1,6 +1,4 @@
export { BillingSummary } from './billing-summary'
export { CancelSubscription } from './cancel-subscription'
export { EditMemberLimitDialog } from './edit-member-limit-dialog'
export { TeamSeatsDialog } from './team-seats-dialog'
export { TeamUsageOverview } from './team-usage-overview'
export { UsageLimitEditor } from './usage-limit-editor'
export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card'
export type { UsageLimitRef } from './usage-limit'
export { UsageLimit } from './usage-limit'

View File

@@ -0,0 +1 @@
export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card'

View File

@@ -0,0 +1,123 @@
'use client'
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { Button } from '@/components/ui'
import { cn } from '@/lib/utils'
export interface PlanFeature {
icon: LucideIcon
text: string
}
export interface PlanCardProps {
name: string
price: string | ReactNode
priceSubtext?: string
features: PlanFeature[]
buttonText: string
onButtonClick: () => void
isError?: boolean
variant?: 'default' | 'compact'
layout?: 'vertical' | 'horizontal'
className?: string
}
/**
* PlanCard component for displaying subscription plan information
* Supports both vertical and horizontal layouts with flexible pricing display
*/
export function PlanCard({
name,
price,
priceSubtext,
features,
buttonText,
onButtonClick,
isError = false,
variant = 'default',
layout = 'vertical',
className,
}: PlanCardProps) {
const isHorizontal = layout === 'horizontal'
const renderPrice = () => {
if (typeof price === 'string') {
return (
<>
<span className='font-semibold text-xl'>{price}</span>
{priceSubtext && (
<span className='ml-1 text-muted-foreground text-xs'>{priceSubtext}</span>
)}
</>
)
}
return price
}
const renderFeatures = () => {
if (isHorizontal) {
return (
<div className='mt-3 flex flex-wrap items-center gap-4'>
{features.map((feature, index) => (
<div key={`${feature.text}-${index}`} className='flex items-center gap-2 text-xs'>
<feature.icon className='h-3 w-3 flex-shrink-0 text-muted-foreground' />
<span className='text-muted-foreground'>{feature.text}</span>
{index < features.length - 1 && (
<div className='ml-4 h-4 w-px bg-border' aria-hidden='true' />
)}
</div>
))}
</div>
)
}
return (
<ul className='mb-4 flex-1 space-y-2'>
{features.map((feature, index) => (
<li key={`${feature.text}-${index}`} className='flex items-start gap-2 text-xs'>
<feature.icon
className='mt-0.5 h-3 w-3 flex-shrink-0 text-muted-foreground'
aria-hidden='true'
/>
<span className='text-muted-foreground'>{feature.text}</span>
</li>
))}
</ul>
)
}
return (
<article
className={cn(
'relative flex rounded-[8px] border p-4 transition-colors hover:border-muted-foreground/20',
isHorizontal ? 'flex-row items-center justify-between' : 'flex-col',
className
)}
>
<header className={isHorizontal ? undefined : 'mb-4'}>
<h3 className='mb-2 font-semibold text-sm'>{name}</h3>
<div className='flex items-baseline'>{renderPrice()}</div>
{isHorizontal && renderFeatures()}
</header>
{!isHorizontal && renderFeatures()}
<div className={isHorizontal ? 'ml-auto' : undefined}>
<Button
onClick={onButtonClick}
className={cn(
'h-9 rounded-[8px] text-xs transition-colors',
isHorizontal ? 'px-4' : 'w-full',
isError &&
'border-red-500 bg-transparent text-red-500 hover:bg-red-500 hover:text-white dark:border-red-500 dark:text-red-500 dark:hover:bg-red-500'
)}
variant={isError ? 'outline' : 'default'}
aria-label={`${buttonText} ${name} plan`}
>
{isError ? 'Error' : buttonText}
</Button>
</div>
</article>
)
}

View File

@@ -1,91 +0,0 @@
import { useEffect, useState } from 'react'
import { Input } from '@/components/ui/input'
import { createLogger } from '@/lib/logs/console/logger'
import { useSubscriptionStore } from '@/stores/subscription/store'
const logger = createLogger('UsageLimitEditor')
interface UsageLimitEditorProps {
currentLimit: number
canEdit: boolean
minimumLimit: number
onLimitUpdated?: (newLimit: number) => void
}
export function UsageLimitEditor({
currentLimit,
canEdit,
minimumLimit,
onLimitUpdated,
}: UsageLimitEditorProps) {
const [inputValue, setInputValue] = useState(currentLimit.toString())
const [isSaving, setIsSaving] = useState(false)
const { updateUsageLimit } = useSubscriptionStore()
useEffect(() => {
setInputValue(currentLimit.toString())
}, [currentLimit])
const handleSubmit = async () => {
const newLimit = Number.parseInt(inputValue, 10)
if (Number.isNaN(newLimit) || newLimit < minimumLimit) {
setInputValue(currentLimit.toString())
return
}
if (newLimit === currentLimit) {
return
}
setIsSaving(true)
try {
const result = await updateUsageLimit(newLimit)
if (!result.success) {
throw new Error(result.error || 'Failed to update limit')
}
setInputValue(newLimit.toString())
onLimitUpdated?.(newLimit)
} catch (error) {
logger.error('Failed to update usage limit', { error })
setInputValue(currentLimit.toString())
} finally {
setIsSaving(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
}
}
return (
<div className='flex items-center'>
<span className='mr-1 text-sm'>$</span>
{canEdit ? (
<Input
type='number'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSubmit}
className='h-8 w-20 font-medium text-sm'
min={minimumLimit}
step='1'
disabled={isSaving}
autoComplete='off'
data-form-type='other'
name='usage-limit'
/>
) : (
<span className='font-medium text-sm'>{currentLimit}</span>
)}
</div>
)
}

View File

@@ -0,0 +1,2 @@
export type { UsageLimitRef } from './usage-limit'
export { UsageLimit } from './usage-limit'

View File

@@ -0,0 +1,209 @@
'use client'
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { Check, Pencil, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useSubscriptionStore } from '@/stores/subscription/store'
const logger = createLogger('UsageLimit')
interface UsageLimitProps {
currentLimit: number
currentUsage: number
canEdit: boolean
minimumLimit: number
onLimitUpdated?: (newLimit: number) => void
}
export interface UsageLimitRef {
startEdit: () => void
}
export const UsageLimit = forwardRef<UsageLimitRef, UsageLimitProps>(
({ currentLimit, currentUsage, canEdit, minimumLimit, onLimitUpdated }, ref) => {
const [inputValue, setInputValue] = useState(currentLimit.toString())
const [isSaving, setIsSaving] = useState(false)
const [hasError, setHasError] = useState(false)
const [errorType, setErrorType] = useState<'general' | 'belowUsage' | null>(null)
const [isEditing, setIsEditing] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const { updateUsageLimit } = useSubscriptionStore()
const handleStartEdit = () => {
if (!canEdit) return
setIsEditing(true)
setInputValue(currentLimit.toString())
}
// Expose startEdit method through ref
useImperativeHandle(
ref,
() => ({
startEdit: handleStartEdit,
}),
[canEdit, currentLimit]
)
useEffect(() => {
setInputValue(currentLimit.toString())
}, [currentLimit])
// Focus input when entering edit mode
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [isEditing])
// Clear error after 2 seconds
useEffect(() => {
if (hasError) {
const timer = setTimeout(() => {
setHasError(false)
setErrorType(null)
}, 2000)
return () => clearTimeout(timer)
}
}, [hasError])
const handleSubmit = async () => {
const newLimit = Number.parseInt(inputValue, 10)
if (Number.isNaN(newLimit) || newLimit < minimumLimit) {
setInputValue(currentLimit.toString())
setIsEditing(false)
return
}
// Check if new limit is below current usage
if (newLimit < currentUsage) {
setHasError(true)
setErrorType('belowUsage')
// Don't reset input value - let user see what they typed
return
}
if (newLimit === currentLimit) {
setIsEditing(false)
return
}
setIsSaving(true)
try {
const result = await updateUsageLimit(newLimit)
if (!result.success) {
throw new Error(result.error || 'Failed to update limit')
}
setInputValue(newLimit.toString())
onLimitUpdated?.(newLimit)
setIsEditing(false)
setErrorType(null)
} catch (error) {
logger.error('Failed to update usage limit', { error })
// Check if the error is about being below current usage
if (error instanceof Error && error.message.includes('below current usage')) {
setErrorType('belowUsage')
} else {
setErrorType('general')
}
setHasError(true)
} finally {
setIsSaving(false)
}
}
const handleCancelEdit = () => {
setIsEditing(false)
setInputValue(currentLimit.toString())
setHasError(false)
setErrorType(null)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
} else if (e.key === 'Escape') {
e.preventDefault()
handleCancelEdit()
}
}
return (
<div className='flex items-center'>
{isEditing ? (
<>
<span className='text-muted-foreground text-xs tabular-nums'>$</span>
<input
ref={inputRef}
type='number'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={(e) => {
// Don't submit if clicking on the button (it will handle submission)
const relatedTarget = e.relatedTarget as HTMLElement
if (relatedTarget?.closest('button')) {
return
}
handleSubmit()
}}
className={cn(
'w-[3ch] border-0 bg-transparent p-0 text-xs tabular-nums',
'outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
'[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none',
hasError && 'text-red-500'
)}
min={minimumLimit}
max='999'
step='1'
disabled={isSaving}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
spellCheck='false'
/>
</>
) : (
<span className='text-muted-foreground text-xs tabular-nums'>${currentLimit}</span>
)}
{canEdit && (
<Button
variant='ghost'
size='icon'
className={cn(
'ml-1 h-4 w-4 p-0 transition-colors hover:bg-transparent',
hasError
? 'text-red-500 hover:text-red-600'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={isEditing ? handleSubmit : handleStartEdit}
disabled={isSaving}
>
{isEditing ? (
hasError ? (
<X className='!h-3 !w-3' />
) : (
<Check className='!h-3 !w-3' />
)
) : (
<Pencil className='!h-3 !w-3' />
)}
<span className='sr-only'>{isEditing ? 'Save limit' : 'Edit limit'}</span>
</Button>
)}
</div>
)
}
)
UsageLimit.displayName = 'UsageLimit'

View File

@@ -0,0 +1,35 @@
import {
Building2,
Clock,
Database,
HeadphonesIcon,
Infinity as InfinityIcon,
MessageSquare,
Server,
Users,
Workflow,
Zap,
} from 'lucide-react'
import type { PlanFeature } from './components/plan-card'
export const PRO_PLAN_FEATURES: PlanFeature[] = [
{ icon: Zap, text: '25 runs per minute (sync)' },
{ icon: Clock, text: '200 runs per minute (async)' },
{ icon: Building2, text: 'Unlimited workspaces' },
{ icon: Workflow, text: 'Unlimited workflows' },
{ icon: Users, text: 'Unlimited invites' },
{ icon: Database, text: 'Unlimited log retention' },
]
export const TEAM_PLAN_FEATURES: PlanFeature[] = [
{ icon: Zap, text: '75 runs per minute (sync)' },
{ icon: Clock, text: '500 runs per minute (async)' },
{ icon: InfinityIcon, text: 'Everything in Pro' },
{ icon: MessageSquare, text: 'Dedicated Slack channel' },
]
export const ENTERPRISE_PLAN_FEATURES: PlanFeature[] = [
{ icon: Zap, text: 'Custom rate limits' },
{ icon: Server, text: 'Enterprise hosting' },
{ icon: HeadphonesIcon, text: 'Dedicated support' },
]

View File

@@ -0,0 +1,69 @@
export interface SubscriptionPermissions {
canUpgradeToPro: boolean
canUpgradeToTeam: boolean
canViewEnterprise: boolean
canManageTeam: boolean
canEditUsageLimit: boolean
canCancelSubscription: boolean
showTeamMemberView: boolean
showUpgradePlans: boolean
}
export interface SubscriptionState {
isFree: boolean
isPro: boolean
isTeam: boolean
isEnterprise: boolean
isPaid: boolean
plan: string
status: string
}
export interface UserRole {
isTeamAdmin: boolean
userRole: string
}
export function getSubscriptionPermissions(
subscription: SubscriptionState,
userRole: UserRole
): SubscriptionPermissions {
const { isFree, isPro, isTeam, isEnterprise, isPaid } = subscription
const { isTeamAdmin } = userRole
return {
canUpgradeToPro: isFree,
canUpgradeToTeam: isFree || (isPro && !isTeam),
canViewEnterprise: !isEnterprise && !(isTeam && !isTeamAdmin), // Don't show to enterprise users or team members
canManageTeam: isTeam && isTeamAdmin,
canEditUsageLimit: (isFree || (isPro && !isTeam)) && !isEnterprise, // Free users see upgrade badge, Pro (non-team) users see pencil
canCancelSubscription: isPaid && !isEnterprise && !(isTeam && !isTeamAdmin), // Team members can't cancel
showTeamMemberView: isTeam && !isTeamAdmin,
showUpgradePlans: isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin), // Free users, Pro users, Team owners see plans
}
}
export function getVisiblePlans(
subscription: SubscriptionState,
userRole: UserRole
): ('pro' | 'team' | 'enterprise')[] {
const plans: ('pro' | 'team' | 'enterprise')[] = []
const { isFree, isPro, isTeam } = subscription
const { isTeamAdmin } = userRole
// Free users see all plans
if (isFree) {
plans.push('pro', 'team', 'enterprise')
}
// Pro users see team and enterprise
else if (isPro && !isTeam) {
plans.push('team', 'enterprise')
}
// Team owners see only enterprise (no team plan since they already have it)
else if (isTeam && isTeamAdmin) {
plans.push('enterprise')
}
// Team members, Enterprise users see no plans
return plans
}

View File

@@ -1,41 +1,188 @@
import { useCallback, useEffect, useState } from 'react'
import { AlertCircle, Users } from 'lucide-react'
import {
Alert,
AlertDescription,
AlertTitle,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Skeleton,
} from '@/components/ui'
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Badge, Progress, Skeleton } from '@/components/ui'
import { useSession, useSubscription } from '@/lib/auth-client'
import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import {
BillingSummary,
CancelSubscription,
TeamSeatsDialog,
UsageLimitEditor,
PlanCard,
UsageLimit,
type UsageLimitRef,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components'
import {
ENTERPRISE_PLAN_FEATURES,
PRO_PLAN_FEATURES,
TEAM_PLAN_FEATURES,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs'
import {
getSubscriptionPermissions,
getVisiblePlans,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription-permissions'
import { useOrganizationStore } from '@/stores/organization'
import { useSubscriptionStore } from '@/stores/subscription/store'
// Logger
const logger = createLogger('Subscription')
// Constants
const CONSTANTS = {
UPGRADE_ERROR_TIMEOUT: 3000, // 3 seconds
TYPEFORM_ENTERPRISE_URL: 'https://form.typeform.com/to/jqCO12pF',
PRO_PRICE: '$20',
TEAM_PRICE: '$40',
INITIAL_TEAM_SEATS: 1,
} as const
// Styles
const STYLES = {
GRADIENT_BADGE:
'gradient-text h-[1.125rem] rounded-[6px] border-gradient-primary/20 bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary px-2 py-0 font-medium text-xs cursor-pointer',
} as const
// Types
type TargetPlan = 'pro' | 'team'
interface SubscriptionProps {
onOpenChange: (open: boolean) => void
}
/**
* Skeleton component for subscription loading state
*/
function SubscriptionSkeleton() {
return (
<div className='px-6 pt-4 pb-4'>
<div className='flex flex-col gap-2'>
{/* Current Plan skeleton - matches usage indicator style */}
<div className='mb-2'>
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Skeleton className='h-5 w-16' />
<Skeleton className='h-[1.125rem] w-14 rounded-[6px]' />
</div>
<div className='flex items-center gap-1 text-xs tabular-nums'>
<Skeleton className='h-4 w-8' />
<span className='text-muted-foreground'>/</span>
<Skeleton className='h-4 w-8' />
</div>
</div>
<Skeleton className='h-2 w-full rounded' />
</div>
</div>
</div>
{/* Plan cards skeleton */}
<div className='flex flex-col gap-2'>
{/* Pro and Team skeleton grid */}
<div className='grid grid-cols-2 gap-2'>
{/* Pro Plan Card Skeleton */}
<div className='flex flex-col rounded-[8px] border p-4'>
<div className='mb-4'>
<Skeleton className='mb-2 h-5 w-8' />
<div className='flex items-baseline'>
<Skeleton className='h-6 w-10' />
<Skeleton className='ml-1 h-3 w-12' />
</div>
</div>
<div className='mb-4 flex-1 space-y-2'>
<div className='flex items-start gap-2'>
<Skeleton className='mt-0.5 h-3 w-3 rounded' />
<Skeleton className='h-3 w-20' />
</div>
<div className='flex items-start gap-2'>
<Skeleton className='mt-0.5 h-3 w-3 rounded' />
<Skeleton className='h-3 w-24' />
</div>
<div className='flex items-start gap-2'>
<Skeleton className='mt-0.5 h-3 w-3 rounded' />
<Skeleton className='h-3 w-16' />
</div>
<div className='flex items-start gap-2'>
<Skeleton className='mt-0.5 h-3 w-3 rounded' />
<Skeleton className='h-3 w-20' />
</div>
</div>
<Skeleton className='h-9 w-full rounded-[8px]' />
</div>
{/* Team Plan Card Skeleton */}
<div className='flex flex-col rounded-[8px] border p-4'>
<div className='mb-4'>
<Skeleton className='mb-2 h-5 w-10' />
<div className='flex items-baseline'>
<Skeleton className='h-6 w-10' />
<Skeleton className='ml-1 h-3 w-12' />
</div>
</div>
<div className='mb-4 flex-1 space-y-2'>
<div className='flex items-start gap-2'>
<Skeleton className='mt-0.5 h-3 w-3 rounded' />
<Skeleton className='h-3 w-24' />
</div>
<div className='flex items-start gap-2'>
<Skeleton className='mt-0.5 h-3 w-3 rounded' />
<Skeleton className='h-3 w-20' />
</div>
<div className='flex items-start gap-2'>
<Skeleton className='mt-0.5 h-3 w-3 rounded' />
<Skeleton className='h-3 w-16' />
</div>
<div className='flex items-start gap-2'>
<Skeleton className='mt-0.5 h-3 w-3 rounded' />
<Skeleton className='h-3 w-28' />
</div>
</div>
<Skeleton className='h-9 w-full rounded-[8px]' />
</div>
</div>
{/* Enterprise skeleton - horizontal layout */}
<div className='flex items-center justify-between rounded-[8px] border p-4'>
<div>
<Skeleton className='mb-2 h-5 w-20' />
<Skeleton className='mb-3 h-3 w-64' />
<div className='flex items-center gap-4'>
<div className='flex items-center gap-2'>
<Skeleton className='h-3 w-3 rounded' />
<Skeleton className='h-3 w-16' />
</div>
<div className='h-4 w-px bg-border' />
<div className='flex items-center gap-2'>
<Skeleton className='h-3 w-3 rounded' />
<Skeleton className='h-3 w-20' />
</div>
<div className='h-4 w-px bg-border' />
<div className='flex items-center gap-2'>
<Skeleton className='h-3 w-3 rounded' />
<Skeleton className='h-3 w-20' />
</div>
</div>
</div>
<Skeleton className='h-9 w-16 rounded-[8px]' />
</div>
</div>
</div>
</div>
)
}
// Utility functions
const formatPlanName = (plan: string): string => plan.charAt(0).toUpperCase() + plan.slice(1)
/**
* Subscription management component
* Handles plan display, upgrades, and billing management
*/
export function Subscription({ onOpenChange }: SubscriptionProps) {
const { data: session } = useSession()
const betterAuthSubscription = useSubscription()
const {
isLoading,
error,
getSubscriptionStatus,
getUsage,
getBillingStatus,
@@ -43,18 +190,13 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
subscriptionData,
} = useSubscriptionStore()
const {
activeOrganization,
organizationBillingData,
isLoadingOrgBilling,
loadOrganizationBillingData,
getUserRole,
addSeats,
} = useOrganizationStore()
const { activeOrganization, organizationBillingData, loadOrganizationBillingData, getUserRole } =
useOrganizationStore()
const [isSeatsDialogOpen, setIsSeatsDialogOpen] = useState(false)
const [isUpdatingSeats, setIsUpdatingSeats] = useState(false)
const [upgradeError, setUpgradeError] = useState<'pro' | 'team' | null>(null)
const usageLimitRef = useRef<UsageLimitRef | null>(null)
// Get real subscription data from store
const subscription = getSubscriptionStatus()
const usage = getUsage()
const billingStatus = getBillingStatus()
@@ -64,19 +206,73 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
if (subscription.isTeam && activeOrgId) {
loadOrganizationBillingData(activeOrgId)
}
}, [activeOrgId, subscription.isTeam])
}, [activeOrgId, subscription.isTeam, loadOrganizationBillingData])
// Determine if user is team admin/owner
// Auto-clear upgrade error
useEffect(() => {
if (upgradeError) {
const timer = setTimeout(() => {
setUpgradeError(null)
}, CONSTANTS.UPGRADE_ERROR_TIMEOUT)
return () => clearTimeout(timer)
}
}, [upgradeError])
// User role and permissions
const userRole = getUserRole(session?.user?.email)
const isTeamAdmin = ['owner', 'admin'].includes(userRole)
const shouldShowOrgBilling = subscription.isTeam && isTeamAdmin && organizationBillingData
// Get permissions based on subscription state and user role
const permissions = getSubscriptionPermissions(
{
isFree: subscription.isFree,
isPro: subscription.isPro,
isTeam: subscription.isTeam,
isEnterprise: subscription.isEnterprise,
isPaid: subscription.isPaid,
plan: subscription.plan || 'free',
status: subscription.status || 'inactive',
},
{
isTeamAdmin,
userRole: userRole || 'member',
}
)
// Get visible plans based on current subscription
const visiblePlans = getVisiblePlans(
{
isFree: subscription.isFree,
isPro: subscription.isPro,
isTeam: subscription.isTeam,
isEnterprise: subscription.isEnterprise,
isPaid: subscription.isPaid,
plan: subscription.plan || 'free',
status: subscription.status || 'inactive',
},
{
isTeamAdmin,
userRole: userRole || 'member',
}
)
// UI state computed values
const showBadge = permissions.canEditUsageLimit && !permissions.showTeamMemberView
const badgeText = subscription.isFree ? 'Upgrade' : 'Add'
const handleBadgeClick = () => {
if (subscription.isFree) {
handleUpgrade('pro')
} else if (permissions.canEditUsageLimit && usageLimitRef.current) {
usageLimitRef.current.startEdit()
}
}
const handleUpgrade = useCallback(
async (targetPlan: 'pro' | 'team') => {
async (targetPlan: TargetPlan) => {
if (!session?.user?.id) return
// Get current subscription data including stripeSubscriptionId
const subscriptionData = useSubscriptionStore.getState().subscriptionData
const { subscriptionData } = useSubscriptionStore.getState()
const currentSubscriptionId = subscriptionData?.stripeSubscriptionId
let referenceId = session.user.id
@@ -84,33 +280,32 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
referenceId = activeOrgId
}
const currentUrl = window.location.origin + window.location.pathname
const currentUrl = `${window.location.origin}${window.location.pathname}`
try {
const upgradeParams: any = {
const upgradeParams = {
plan: targetPlan,
referenceId,
successUrl: currentUrl,
cancelUrl: currentUrl,
seats: targetPlan === 'team' ? 1 : undefined,
}
...(targetPlan === 'team' && { seats: CONSTANTS.INITIAL_TEAM_SEATS }),
} as const
// Add subscriptionId if we have an existing subscription to ensure proper plan switching
if (currentSubscriptionId) {
upgradeParams.subscriptionId = currentSubscriptionId
logger.info('Upgrading existing subscription', {
// Add subscriptionId for existing subscriptions to ensure proper plan switching
const finalParams = currentSubscriptionId
? { ...upgradeParams, subscriptionId: currentSubscriptionId }
: upgradeParams
logger.info(
currentSubscriptionId ? 'Upgrading existing subscription' : 'Creating new subscription',
{
targetPlan,
currentSubscriptionId,
referenceId,
})
} else {
logger.info('Creating new subscription (no existing subscription found)', {
targetPlan,
referenceId,
})
}
}
)
await betterAuthSubscription.upgrade(upgradeParams)
await betterAuthSubscription.upgrade(finalParams)
} catch (error) {
logger.error('Failed to initiate subscription upgrade:', error)
alert('Failed to initiate upgrade. Please try again or contact support.')
@@ -119,310 +314,213 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
[session?.user?.id, subscription.isTeam, activeOrgId, betterAuthSubscription]
)
const handleSeatsUpdate = useCallback(
async (seats: number) => {
if (!activeOrgId) {
logger.error('No active organization found for seat update')
return
}
const renderPlanCard = useCallback(
(planType: 'pro' | 'team' | 'enterprise', layout: 'vertical' | 'horizontal' = 'vertical') => {
const handleContactEnterprise = () => window.open(CONSTANTS.TYPEFORM_ENTERPRISE_URL, '_blank')
try {
setIsUpdatingSeats(true)
await addSeats(seats)
setIsSeatsDialogOpen(false)
} catch (error) {
logger.error('Failed to update seats:', error)
} finally {
setIsUpdatingSeats(false)
switch (planType) {
case 'pro':
return (
<PlanCard
key='pro'
name='Pro'
price={CONSTANTS.PRO_PRICE}
priceSubtext='/month'
features={PRO_PLAN_FEATURES}
buttonText={subscription.isFree ? 'Upgrade' : 'Upgrade to Pro'}
onButtonClick={() => handleUpgrade('pro')}
isError={upgradeError === 'pro'}
layout={layout}
/>
)
case 'team':
return (
<PlanCard
key='team'
name='Team'
price={CONSTANTS.TEAM_PRICE}
priceSubtext='/month'
features={TEAM_PLAN_FEATURES}
buttonText={subscription.isFree ? 'Upgrade' : 'Upgrade to Team'}
onButtonClick={() => handleUpgrade('team')}
isError={upgradeError === 'team'}
layout={layout}
/>
)
case 'enterprise':
return (
<PlanCard
key='enterprise'
name='Enterprise'
price={<span className='font-semibold text-xl'>Custom</span>}
priceSubtext={
layout === 'horizontal'
? 'Custom solutions tailored to your enterprise needs'
: undefined
}
features={ENTERPRISE_PLAN_FEATURES}
buttonText='Contact'
onButtonClick={handleContactEnterprise}
layout={layout}
/>
)
default:
return null
}
},
[activeOrgId]
[subscription.isFree, upgradeError, handleUpgrade]
)
if (isLoading) {
return (
<div className='space-y-4 p-6'>
<Skeleton className='h-4 w-full' />
<Skeleton className='h-20 w-full' />
<Skeleton className='h-4 w-3/4' />
</div>
)
}
if (error) {
return (
<div className='p-6'>
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</div>
)
return <SubscriptionSkeleton />
}
return (
<div className='p-6'>
<div className='space-y-6'>
{/* Current Plan & Usage Overview */}
<div>
<div className='mb-2 flex items-center justify-between'>
<h3 className='font-medium text-sm'>Current Plan</h3>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-sm capitalize'>
{subscription.plan} Plan
</span>
{!subscription.isFree && <BillingSummary showDetails={false} />}
</div>
</div>
<div className='px-6 pt-4 pb-4'>
<div className='flex flex-col gap-2'>
{/* Current Plan & Usage Overview - Styled like usage-indicator */}
<div className='mb-2'>
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
<div className='space-y-2'>
{/* Plan and usage info */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span
className={cn(
'font-medium text-sm',
subscription.isFree
? 'text-foreground'
: 'gradient-text bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary'
)}
>
{formatPlanName(subscription.plan)}
</span>
{showBadge && (
<Badge
className={STYLES.GRADIENT_BADGE}
onClick={(e) => {
e.stopPropagation()
handleBadgeClick()
}}
>
{badgeText}
</Badge>
)}
{/* Team seats info for admins */}
{permissions.canManageTeam && (
<span className='text-muted-foreground text-xs'>
({organizationBillingData?.totalSeats || subscription.seats || 1} seats)
</span>
)}
</div>
<div className='flex items-center gap-1 text-xs tabular-nums'>
<span className='text-muted-foreground'>${usage.current.toFixed(2)}</span>
<span className='text-muted-foreground'>/</span>
{!subscription.isFree &&
(permissions.canEditUsageLimit ||
permissions.showTeamMemberView ||
subscription.isEnterprise) ? (
<UsageLimit
ref={usageLimitRef}
currentLimit={usageLimitData?.currentLimit || usage.limit}
currentUsage={usage.current}
canEdit={permissions.canEditUsageLimit && !subscription.isEnterprise}
minimumLimit={usageLimitData?.minimumLimit || (subscription.isPro ? 20 : 40)}
/>
) : (
<span className='text-muted-foreground'>${usage.limit}</span>
)}
</div>
</div>
<div className='mb-3 flex items-center justify-between'>
<span className='font-semibold text-2xl'>
${usage.current.toFixed(2)} / ${usage.limit}
</span>
<div className='text-right'>
<span className='block text-muted-foreground text-sm'>
{usage.percentUsed}% used this period
</span>
{/* Progress Bar */}
<Progress value={Math.min(usage.percentUsed, 100)} className='h-2' />
</div>
</div>
</div>
{/* Usage Alerts */}
{billingStatus === 'exceeded' && (
<Alert variant='destructive'>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Usage Limit Exceeded</AlertTitle>
<AlertDescription>
You've exceeded your usage limit of ${usage.limit}. Please upgrade your plan or
increase your limit.
</AlertDescription>
</Alert>
)}
{billingStatus === 'warning' && (
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertTitle>Approaching Usage Limit</AlertTitle>
<AlertDescription>
You've used {usage.percentUsed}% of your ${usage.limit} limit. Consider upgrading or
increasing your limit.
</AlertDescription>
</Alert>
)}
{/* Usage Limit Editor */}
<div>
<div className='flex items-center justify-between'>
<span className='font-medium text-sm'>
{subscription.isTeam ? 'Individual Limit' : 'Monthly Limit'}
</span>
{isLoadingOrgBilling ? (
<Skeleton className='h-8 w-16' />
) : (
<UsageLimitEditor
currentLimit={usageLimitData?.currentLimit ?? usage.limit}
canEdit={
subscription.isPro ||
subscription.isTeam ||
subscription.isEnterprise ||
(subscription.isTeam && isTeamAdmin)
}
minimumLimit={usageLimitData?.minimumLimit ?? DEFAULT_FREE_CREDITS}
/>
)}
</div>
{subscription.isFree && (
<p className='mt-1 text-muted-foreground text-xs'>
Upgrade to Pro ($20 minimum) or Team ($40 minimum) to customize your usage limit.
{/* Team Member Notice */}
{permissions.showTeamMemberView && (
<div className='text-center'>
<p className='text-muted-foreground text-xs'>
Contact your team admin to increase limits
</p>
)}
{subscription.isPro && (
<p className='mt-1 text-muted-foreground text-xs'>
Pro plan minimum: $20. You can set your individual limit higher.
</p>
)}
{subscription.isTeam && !isTeamAdmin && (
<p className='mt-1 text-muted-foreground text-xs'>
Contact your team owner to adjust your limit. Team plan minimum: $40.
</p>
)}
{subscription.isTeam && isTeamAdmin && (
<p className='mt-1 text-muted-foreground text-xs'>
Team plan minimum: $40 per member. Manage team member limits in the Team tab.
</p>
)}
</div>
{/* Team Management */}
{subscription.isTeam && (
<div className='space-y-4'>
{isLoadingOrgBilling ? (
<Card>
<CardHeader className='pb-3'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Skeleton className='h-5 w-5' />
<Skeleton className='h-6 w-24' />
</div>
<Skeleton className='h-8 w-24' />
</div>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between'>
<div className='space-y-1'>
<Skeleton className='h-4 w-20' />
<Skeleton className='h-6 w-32' />
</div>
<div className='space-y-1 text-right'>
<Skeleton className='h-4 w-24' />
<Skeleton className='h-6 w-16' />
</div>
</div>
<Skeleton className='h-2 w-full' />
</CardContent>
</Card>
) : shouldShowOrgBilling ? (
<Card>
<CardHeader className='pb-3'>
<div className='flex items-center justify-between'>
<CardTitle className='flex items-center gap-2 text-lg'>
<Users className='h-5 w-5' />
Team Plan
</CardTitle>
</div>
</CardHeader>
<CardContent className='space-y-4'>
{/* Team Summary */}
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Licensed Seats</span>
<span className='font-semibold'>
{organizationBillingData.totalSeats} seats
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Monthly Bill</span>
<span className='font-semibold'>
${organizationBillingData.totalSeats * 40}
</span>
</div>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Current Usage</span>
<span className='font-semibold'>
${organizationBillingData.totalCurrentUsage?.toFixed(2) || 0}
</span>
</div>
</div>
{/* Simple Explanation */}
<div className='rounded-lg bg-muted/50 p-3 text-muted-foreground text-sm'>
<p>
You pay ${organizationBillingData.totalSeats * 40}/month for{' '}
{organizationBillingData.totalSeats} licensed seats, regardless of usage. If
your team uses more than ${organizationBillingData.totalSeats * 40}, you'll be
charged for the overage.
</p>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardHeader className='pb-3'>
<CardTitle className='flex items-center gap-2 text-lg'>
<Users className='h-5 w-5' />
Team Plan
</CardTitle>
</CardHeader>
<CardContent>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<span className='text-muted-foreground text-sm'>Your monthly allowance</span>
<span className='font-semibold'>${usage.limit}</span>
</div>
<p className='text-muted-foreground text-xs'>
Contact your team owner to adjust your limit
</p>
</div>
</CardContent>
</Card>
)}
</div>
)}
{/* Upgrade Actions */}
{subscription.isFree && (
<div className='space-y-3'>
<Button onClick={() => handleUpgrade('pro')} className='w-full'>
Upgrade to Pro - $20/month
</Button>
<Button onClick={() => handleUpgrade('team')} variant='outline' className='w-full'>
Upgrade to Team - $40/seat/month
</Button>
<div className='py-2 text-center'>
<p className='text-muted-foreground text-xs'>
Need a custom plan?{' '}
<a
href='https://5fyxh22cfgi.typeform.com/to/EcJFBt9W'
target='_blank'
rel='noopener noreferrer'
className='text-blue-500 hover:underline'
>
Contact us
</a>{' '}
for Enterprise pricing
</p>
</div>
</div>
)}
{/* Upgrade Plans */}
{permissions.showUpgradePlans && (
<div className='flex flex-col gap-2'>
{/* Render plans based on what should be visible */}
{(() => {
const totalPlans = visiblePlans.length
const hasEnterprise = visiblePlans.includes('enterprise')
{subscription.isPro && !subscription.isTeam && (
<Button onClick={() => handleUpgrade('team')} className='w-full'>
Upgrade to Team - $40/seat/month
</Button>
// Special handling for Pro users - show team and enterprise side by side
if (subscription.isPro && totalPlans === 2) {
return (
<div className='grid grid-cols-2 gap-2'>
{visiblePlans.map((plan) => renderPlanCard(plan, 'vertical'))}
</div>
)
}
// Default behavior for other users
const otherPlans = visiblePlans.filter((p) => p !== 'enterprise')
// Layout logic:
// Free users (3 plans): Pro and Team vertical in grid, Enterprise horizontal below
// Team admins (1 plan): Enterprise horizontal
const enterpriseLayout =
totalPlans === 1 || totalPlans === 3 ? 'horizontal' : 'vertical'
return (
<>
{otherPlans.length > 0 && (
<div
className={cn(
'grid gap-2',
otherPlans.length === 1 ? 'grid-cols-1' : 'grid-cols-2'
)}
>
{otherPlans.map((plan) => renderPlanCard(plan, 'vertical'))}
</div>
)}
{/* Enterprise plan */}
{hasEnterprise && renderPlanCard('enterprise', enterpriseLayout)}
</>
)
})()}
</div>
)}
{subscription.isEnterprise && (
<div className='py-2 text-center'>
<p className='text-muted-foreground text-sm'>
Enterprise plan - Contact support for changes
<div className='text-center'>
<p className='text-muted-foreground text-xs'>
Contact enterprise for support usage limit changes
</p>
</div>
)}
{/* Cancel Subscription */}
<CancelSubscription
subscription={{
plan: subscription.plan,
status: subscription.status,
isPaid: subscription.isPaid,
}}
subscriptionData={{
periodEnd: subscriptionData?.periodEnd || null,
}}
/>
{/* Team Seats Dialog */}
<TeamSeatsDialog
open={isSeatsDialogOpen}
onOpenChange={setIsSeatsDialogOpen}
title='Update Team Seats'
description='Each seat costs $40/month and provides $40 in monthly inference credits. Adjust the number of licensed seats for your team.'
currentSeats={
shouldShowOrgBilling
? organizationBillingData?.totalSeats || 1
: subscription.seats || 1
}
initialSeats={
shouldShowOrgBilling
? organizationBillingData?.totalSeats || 1
: subscription.seats || 1
}
isLoading={isUpdatingSeats}
onConfirm={handleSeatsUpdate}
confirmButtonText='Update Seats'
showCostBreakdown={true}
/>
{permissions.canCancelSubscription && (
<div className='mt-2'>
<CancelSubscription
subscription={{
plan: subscription.plan,
status: subscription.status,
isPaid: subscription.isPaid,
}}
subscriptionData={{
periodEnd: subscriptionData?.periodEnd || null,
}}
/>
</div>
)}
</div>
</div>
)

View File

@@ -1,8 +1,11 @@
export { MemberInvitationCard } from './member-invitation-card'
export { MemberLimit } from './member-limit'
export { NoOrganizationView } from './no-organization-view'
export { OrganizationCreationDialog } from './organization-creation-dialog'
export { OrganizationSettingsTab } from './organization-settings-tab'
export { PendingInvitationsList } from './pending-invitations-list'
export { RemoveMemberDialog } from './remove-member-dialog'
export { TeamMembersList } from './team-members-list'
export { TeamSeats } from './team-seats'
export { TeamSeatsOverview } from './team-seats-overview'
export { TeamUsage } from './team-usage'

View File

@@ -0,0 +1 @@
export { MemberInvitationCard } from './member-invitation-card'

View File

@@ -98,14 +98,14 @@ export function MemberInvitationCard({
const selectedCount = selectedWorkspaces.length
return (
<Card>
<CardHeader className='pb-4'>
<CardTitle className='text-base'>Invite Team Members</CardTitle>
<Card className='rounded-[8px] shadow-xs'>
<CardHeader className='p-4 pb-3'>
<CardTitle className='font-medium text-sm'>Invite Team Members</CardTitle>
<CardDescription>
Add new members to your team and optionally give them access to specific workspaces
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<CardContent className='space-y-4 p-4 pt-0'>
<div className='flex items-center gap-3'>
<div className='flex-1'>
<Input
@@ -126,11 +126,14 @@ export function MemberInvitationCard({
}
}}
disabled={isInviting}
className='shrink-0 gap-1'
className='h-9 shrink-0 gap-1 rounded-[8px]'
>
{showWorkspaceInvite ? 'Hide' : 'Add'} Workspaces
{selectedCount > 0 && (
<Badge variant='secondary' className='ml-1 h-5 px-1.5 text-xs'>
<Badge
variant='secondary'
className='ml-1 h-[1.125rem] rounded-[6px] px-2 py-0 text-xs'
>
{selectedCount}
</Badge>
)}
@@ -142,7 +145,7 @@ export function MemberInvitationCard({
size='sm'
onClick={onInviteMember}
disabled={!inviteEmail || isInviting}
className='shrink-0 gap-2'
className='h-9 shrink-0 gap-2 rounded-[8px]'
>
{isInviting ? <ButtonSkeleton /> : <PlusCircle className='h-4 w-4' />}
Invite
@@ -153,8 +156,8 @@ export function MemberInvitationCard({
<div className='space-y-3 pt-1'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<h5 className='font-medium text-sm'>Workspace Access</h5>
<Badge variant='outline' className='text-xs'>
<h5 className='font-medium text-xs'>Workspace Access</h5>
<Badge variant='outline' className='h-[1.125rem] rounded-[6px] px-2 py-0 text-xs'>
Optional
</Badge>
</div>
@@ -174,7 +177,7 @@ export function MemberInvitationCard({
</p>
</div>
) : (
<div className='max-h-48 space-y-2 overflow-y-auto rounded-md border bg-muted/20 p-3'>
<div className='max-h-48 space-y-2 overflow-y-auto rounded-[8px] border bg-muted/20 p-3'>
{userWorkspaces.map((workspace) => {
const isSelected = selectedWorkspaces.some((w) => w.workspaceId === workspace.id)
const selectedWorkspace = selectedWorkspaces.find(
@@ -185,13 +188,13 @@ export function MemberInvitationCard({
<div
key={workspace.id}
className={cn(
'flex items-center justify-between rounded-md border bg-background p-3 transition-all',
'flex items-center justify-between rounded-[8px] border bg-background p-3 transition-all',
isSelected
? 'border-primary/20 bg-primary/5'
: 'hover:border-border hover:bg-muted/50'
)}
>
<div className='flex items-center gap-3'>
<div className='flex items-center gap-2'>
<Checkbox
id={`workspace-${workspace.id}`}
checked={isSelected}
@@ -206,12 +209,15 @@ export function MemberInvitationCard({
/>
<Label
htmlFor={`workspace-${workspace.id}`}
className='cursor-pointer font-medium text-sm leading-none'
className='cursor-pointer font-medium text-xs leading-none'
>
{workspace.name}
</Label>
{workspace.isOwner && (
<Badge variant='outline' className='text-xs'>
<Badge
variant='outline'
className='h-[1.125rem] rounded-[6px] px-2 py-0 text-xs'
>
Owner
</Badge>
)}
@@ -242,7 +248,7 @@ export function MemberInvitationCard({
)}
{inviteSuccess && (
<Alert className='border-green-200 bg-green-50 text-green-800 dark:border-green-800/50 dark:bg-green-950/20 dark:text-green-300'>
<Alert className='rounded-[8px] border-green-200 bg-green-50 text-green-800 dark:border-green-800/50 dark:bg-green-950/20 dark:text-green-300'>
<CheckCircle className='h-4 w-4 text-green-600 dark:text-green-400' />
<AlertDescription>
Invitation sent successfully

View File

@@ -14,7 +14,7 @@ import {
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
interface EditMemberLimitDialogProps {
interface MemberLimitProps {
open: boolean
onOpenChange: (open: boolean) => void
member: {
@@ -30,14 +30,14 @@ interface EditMemberLimitDialogProps {
planType?: string
}
export function EditMemberLimitDialog({
export function MemberLimit({
open,
onOpenChange,
member,
onSave,
isLoading,
planType = 'team',
}: EditMemberLimitDialogProps) {
}: MemberLimitProps) {
const [limitValue, setLimitValue] = useState('')
const [error, setError] = useState<string | null>(null)

View File

@@ -0,0 +1 @@
export { NoOrganizationView } from './no-organization-view'

View File

@@ -2,7 +2,7 @@ import { RefreshCw } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { OrganizationCreationDialog } from './'
import { OrganizationCreationDialog } from '../organization-creation-dialog'
interface NoOrganizationViewProps {
hasTeamPlan: boolean
@@ -35,11 +35,11 @@ export function NoOrganizationView({
}: NoOrganizationViewProps) {
if (hasTeamPlan || hasEnterprisePlan) {
return (
<div className='space-y-6 p-6'>
<div className='space-y-4 p-6'>
<div className='space-y-6'>
<h3 className='font-medium text-lg'>Create Your Team Workspace</h3>
<h3 className='font-medium text-sm'>Create Your Team Workspace</h3>
<div className='space-y-6 rounded-lg border p-6'>
<div className='space-y-4 rounded-[8px] border p-4 shadow-xs'>
<p className='text-muted-foreground text-sm'>
You're subscribed to a {hasEnterprisePlan ? 'enterprise' : 'team'} plan. Create your
workspace to start collaborating with your team.
@@ -47,7 +47,7 @@ export function NoOrganizationView({
<div className='space-y-4'>
<div className='space-y-2'>
<label htmlFor='orgName' className='font-medium text-sm'>
<label htmlFor='orgName' className='font-medium text-xs'>
Team Name
</label>
<Input
@@ -59,11 +59,11 @@ export function NoOrganizationView({
</div>
<div className='space-y-2'>
<label htmlFor='orgSlug' className='font-medium text-sm'>
<label htmlFor='orgSlug' className='font-medium text-xs'>
Team URL
</label>
<div className='flex items-center space-x-2'>
<div className='rounded-l-md bg-muted px-3 py-2 text-muted-foreground text-sm'>
<div className='rounded-l-[8px] bg-muted px-3 py-2 text-muted-foreground text-xs'>
sim.ai/team/
</div>
<Input
@@ -77,7 +77,7 @@ export function NoOrganizationView({
</div>
{error && (
<Alert variant='destructive'>
<Alert variant='destructive' className='rounded-[8px]'>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
@@ -87,6 +87,7 @@ export function NoOrganizationView({
<Button
onClick={onCreateOrganization}
disabled={!orgName || !orgSlug || isCreatingOrg}
className='h-9 rounded-[8px]'
>
{isCreatingOrg && <RefreshCw className='mr-2 h-4 w-4 animate-spin' />}
Create Team Workspace
@@ -111,9 +112,9 @@ export function NoOrganizationView({
}
return (
<div className='space-y-6 p-6'>
<div className='space-y-4 p-6'>
<div className='space-y-6'>
<h3 className='font-medium text-lg'>No Team Workspace</h3>
<h3 className='font-medium text-sm'>No Team Workspace</h3>
<p className='text-muted-foreground text-sm'>
You don't have a team workspace yet. To collaborate with others, first upgrade to a team
or enterprise plan.
@@ -127,6 +128,7 @@ export function NoOrganizationView({
})
window.dispatchEvent(event)
}}
className='h-9 rounded-[8px]'
>
Upgrade to Team Plan
</Button>

View File

@@ -0,0 +1 @@
export { OrganizationCreationDialog } from './organization-creation-dialog'

View File

@@ -46,18 +46,18 @@ export function OrganizationCreationDialog({
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<label htmlFor='orgName' className='font-medium text-sm'>
<label htmlFor='orgName' className='font-medium text-xs'>
Team Name
</label>
<Input id='orgName' value={orgName} onChange={onOrgNameChange} placeholder='My Team' />
</div>
<div className='space-y-2'>
<label htmlFor='orgSlug' className='font-medium text-sm'>
<label htmlFor='orgSlug' className='font-medium text-xs'>
Team URL
</label>
<div className='flex items-center space-x-2'>
<div className='rounded-l-md bg-muted px-3 py-2 text-muted-foreground text-sm'>
<div className='rounded-l-[8px] bg-muted px-3 py-2 text-muted-foreground text-xs'>
sim.ai/team/
</div>
<Input
@@ -70,17 +70,26 @@ export function OrganizationCreationDialog({
</div>
{error && (
<Alert variant='destructive'>
<Alert variant='destructive' className='rounded-[8px]'>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)} disabled={isCreating}>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
disabled={isCreating}
className='h-9 rounded-[8px]'
>
Cancel
</Button>
<Button onClick={onCreateOrganization} disabled={!orgName || !orgSlug || isCreating}>
<Button
onClick={onCreateOrganization}
disabled={!orgName || !orgSlug || isCreating}
className='h-9 rounded-[8px]'
>
{isCreating && <RefreshCw className='mr-2 h-4 w-4 animate-spin' />}
Create Team Workspace
</Button>

View File

@@ -0,0 +1 @@
export { OrganizationSettingsTab } from './organization-settings-tab'

View File

@@ -28,23 +28,23 @@ export function OrganizationSettingsTab({
orgSettingsSuccess,
}: OrganizationSettingsTabProps) {
return (
<div className='mt-4 space-y-6'>
<div className='mt-4 space-y-4'>
{orgSettingsError && (
<Alert variant='destructive'>
<Alert variant='destructive' className='rounded-[8px]'>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{orgSettingsError}</AlertDescription>
</Alert>
)}
{orgSettingsSuccess && (
<Alert>
<Alert className='rounded-[8px]'>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{orgSettingsSuccess}</AlertDescription>
</Alert>
)}
{!isAdminOrOwner && (
<Alert>
<Alert className='rounded-[8px]'>
<AlertTitle>Read Only</AlertTitle>
<AlertDescription>
You need owner or admin permissions to modify team settings.
@@ -52,12 +52,12 @@ export function OrganizationSettingsTab({
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className='text-base'>Basic Information</CardTitle>
<Card className='rounded-[8px] shadow-xs'>
<CardHeader className='p-4 pb-3'>
<CardTitle className='font-medium text-sm'>Basic Information</CardTitle>
<CardDescription>Update your team's basic information and branding</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<CardContent className='space-y-4 p-4 pt-0'>
<div className='space-y-2'>
<Label htmlFor='team-name'>Team Name</Label>
<Input
@@ -112,11 +112,11 @@ export function OrganizationSettingsTab({
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className='text-base'>Team Information</CardTitle>
<Card className='rounded-[8px] shadow-xs'>
<CardHeader className='p-4 pb-3'>
<CardTitle className='font-medium text-sm'>Team Information</CardTitle>
</CardHeader>
<CardContent className='space-y-2 text-sm'>
<CardContent className='space-y-2 p-4 pt-0 text-xs'>
<div className='flex justify-between'>
<span className='text-muted-foreground'>Team ID:</span>
<span className='font-mono'>{organization.id}</span>

View File

@@ -0,0 +1 @@
export { PendingInvitationsList } from './pending-invitations-list'

View File

@@ -20,8 +20,8 @@ export function PendingInvitationsList({
}
return (
<div className='rounded-md border'>
<h4 className='border-b p-4 font-medium text-sm'>Pending Invitations</h4>
<div className='rounded-[8px] border shadow-xs'>
<h4 className='border-b p-4 pb-3 font-medium text-sm'>Pending Invitations</h4>
<div className='divide-y'>
{pendingInvitations.map((invitation: Invitation) => (
<div key={invitation.id} className='flex items-center justify-between p-4'>
@@ -31,13 +31,18 @@ export function PendingInvitationsList({
{invitation.email.charAt(0).toUpperCase()}
</div>
<div className='flex-1'>
<div className='font-medium'>{invitation.email}</div>
<div className='text-muted-foreground text-sm'>Invitation pending</div>
<div className='font-medium text-sm'>{invitation.email}</div>
<div className='text-muted-foreground text-xs'>Invitation pending</div>
</div>
</div>
</div>
<Button variant='outline' size='sm' onClick={() => onCancelInvitation(invitation.id)}>
<Button
variant='outline'
size='sm'
onClick={() => onCancelInvitation(invitation.id)}
className='h-8 w-8 rounded-[8px] p-0'
>
<X className='h-4 w-4' />
</Button>
</div>

View File

@@ -0,0 +1 @@
export { RemoveMemberDialog } from './remove-member-dialog'

View File

@@ -42,11 +42,11 @@ export function RemoveMemberDialog({
<input
type='checkbox'
id='reduce-seats'
className='rounded'
className='rounded-[4px]'
checked={shouldReduceSeats}
onChange={(e) => onShouldReduceSeatsChange(e.target.checked)}
/>
<label htmlFor='reduce-seats' className='text-sm'>
<label htmlFor='reduce-seats' className='text-xs'>
Also reduce seat count in my subscription
</label>
</div>
@@ -56,10 +56,14 @@ export function RemoveMemberDialog({
</div>
<DialogFooter>
<Button variant='outline' onClick={onCancel}>
<Button variant='outline' onClick={onCancel} className='h-9 rounded-[8px]'>
Cancel
</Button>
<Button variant='destructive' onClick={() => onConfirmRemove(shouldReduceSeats)}>
<Button
variant='destructive'
onClick={() => onConfirmRemove(shouldReduceSeats)}
className='h-9 rounded-[8px]'
>
Remove
</Button>
</DialogFooter>

View File

@@ -17,8 +17,8 @@ export function TeamMembersList({
}: TeamMembersListProps) {
if (!organization.members || organization.members.length === 0) {
return (
<div className='rounded-md border'>
<h4 className='border-b p-4 font-medium text-sm'>Team Members</h4>
<div className='rounded-[8px] border shadow-xs'>
<h4 className='border-b p-4 pb-3 font-medium text-sm'>Team Members</h4>
<div className='p-4 text-muted-foreground text-sm'>
No members in this organization yet.
</div>
@@ -27,8 +27,8 @@ export function TeamMembersList({
}
return (
<div className='rounded-md border'>
<h4 className='border-b p-4 font-medium text-sm'>Team Members</h4>
<div className='rounded-[8px] border shadow-xs'>
<h4 className='border-b p-4 pb-3 font-medium text-sm'>Team Members</h4>
<div className='divide-y'>
{organization.members.map((member: Member) => (
<div key={member.id} className='flex items-center justify-between p-4'>
@@ -38,10 +38,10 @@ export function TeamMembersList({
{(member.user?.name || member.user?.email || 'U').charAt(0).toUpperCase()}
</div>
<div className='flex-1'>
<div className='font-medium'>{member.user?.name || 'Unknown'}</div>
<div className='text-muted-foreground text-sm'>{member.user?.email}</div>
<div className='font-medium text-sm'>{member.user?.name || 'Unknown'}</div>
<div className='text-muted-foreground text-xs'>{member.user?.email}</div>
</div>
<div className='rounded-full bg-primary/10 px-3 py-1 font-medium text-primary text-xs'>
<div className='h-[1.125rem] rounded-[6px] bg-primary/10 px-2 py-0 font-medium text-primary text-xs'>
{member.role.charAt(0).toUpperCase() + member.role.slice(1)}
</div>
</div>
@@ -51,7 +51,12 @@ export function TeamMembersList({
{isAdminOrOwner &&
member.role !== 'owner' &&
member.user?.email !== currentUserEmail && (
<Button variant='outline' size='sm' onClick={() => onRemoveMember(member)}>
<Button
variant='outline'
size='sm'
onClick={() => onRemoveMember(member)}
className='h-8 w-8 rounded-[8px] p-0'
>
<UserX className='h-4 w-4' />
</Button>
)}

View File

@@ -47,12 +47,12 @@ export function TeamSeatsOverview({
}: TeamSeatsOverviewProps) {
if (isLoadingSubscription) {
return (
<Card>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Team Seats Overview</CardTitle>
<Card className='rounded-[8px] shadow-xs'>
<CardHeader className='p-4 pb-3'>
<CardTitle className='font-medium text-sm'>Team Seats Overview</CardTitle>
<CardDescription>Manage your team's seat allocation and billing</CardDescription>
</CardHeader>
<CardContent>
<CardContent className='p-4 pt-0'>
<TeamSeatsSkeleton />
</CardContent>
</Card>
@@ -61,18 +61,18 @@ export function TeamSeatsOverview({
if (!subscriptionData) {
return (
<Card>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Team Seats Overview</CardTitle>
<Card className='rounded-[8px] shadow-xs'>
<CardHeader className='p-4 pb-3'>
<CardTitle className='font-medium text-sm'>Team Seats Overview</CardTitle>
<CardDescription>Manage your team's seat allocation and billing</CardDescription>
</CardHeader>
<CardContent>
<CardContent className='p-4 pt-0'>
<div className='space-y-4 p-6 text-center'>
<div className='mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-amber-100'>
<Building2 className='h-6 w-6 text-amber-600' />
</div>
<div className='space-y-2'>
<p className='font-medium'>No Team Subscription Found</p>
<p className='font-medium text-sm'>No Team Subscription Found</p>
<p className='text-muted-foreground text-sm'>
Your subscription may need to be transferred to this organization.
</p>
@@ -82,6 +82,7 @@ export function TeamSeatsOverview({
onConfirmTeamUpgrade(2) // Start with 2 seats as default
}}
disabled={isLoading}
className='h-9 rounded-[8px]'
>
Set Up Team Subscription
</Button>
@@ -92,24 +93,24 @@ export function TeamSeatsOverview({
}
return (
<Card>
<Card className='rounded-[8px] shadow-xs'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Team Seats Overview</CardTitle>
<CardDescription>Manage your team's seat allocation and billing</CardDescription>
</CardHeader>
<CardContent>
<CardContent className='p-4 pt-0'>
<div className='space-y-4'>
<div className='grid grid-cols-3 gap-4 text-center'>
<div className='space-y-1'>
<p className='font-bold text-2xl'>{subscriptionData.seats || 0}</p>
<p className='font-bold text-xl'>{subscriptionData.seats || 0}</p>
<p className='text-muted-foreground text-xs'>Licensed Seats</p>
</div>
<div className='space-y-1'>
<p className='font-bold text-2xl'>{usedSeats}</p>
<p className='font-bold text-xl'>{usedSeats}</p>
<p className='text-muted-foreground text-xs'>Used Seats</p>
</div>
<div className='space-y-1'>
<p className='font-bold text-2xl'>{(subscriptionData.seats || 0) - usedSeats}</p>
<p className='font-bold text-xl'>{(subscriptionData.seats || 0) - usedSeats}</p>
<p className='text-muted-foreground text-xs'>Available</p>
</div>
</div>
@@ -121,7 +122,7 @@ export function TeamSeatsOverview({
{usedSeats} of {subscriptionData.seats || 0} seats
</span>
</div>
<Progress value={(usedSeats / (subscriptionData.seats || 1)) * 100} className='h-3' />
<Progress value={(usedSeats / (subscriptionData.seats || 1)) * 100} className='h-2' />
</div>
<div className='flex items-center justify-between border-t pt-2 text-sm'>
@@ -135,7 +136,7 @@ export function TeamSeatsOverview({
</div>
{checkEnterprisePlan(subscriptionData) ? (
<div className='rounded-lg bg-purple-50 p-4 text-center'>
<div className='rounded-[8px] bg-purple-50 p-4 text-center'>
<p className='font-medium text-purple-700 text-sm'>Enterprise Plan</p>
<p className='mt-1 text-purple-600 text-xs'>Contact support to modify seats</p>
</div>
@@ -146,11 +147,16 @@ export function TeamSeatsOverview({
size='sm'
onClick={onReduceSeats}
disabled={(subscriptionData.seats || 0) <= 1 || isLoading}
className='flex-1'
className='h-9 flex-1 rounded-[8px]'
>
Remove Seat
</Button>
<Button size='sm' onClick={onAddSeatDialog} disabled={isLoading} className='flex-1'>
<Button
size='sm'
onClick={onAddSeatDialog}
disabled={isLoading}
className='h-9 flex-1 rounded-[8px]'
>
Add Seat
</Button>
</div>

View File

@@ -18,7 +18,7 @@ import {
} from '@/components/ui/select'
import { env } from '@/lib/env'
interface TeamSeatsDialogProps {
interface TeamSeatsProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
@@ -31,7 +31,7 @@ interface TeamSeatsDialogProps {
showCostBreakdown?: boolean
}
export function TeamSeatsDialog({
export function TeamSeats({
open,
onOpenChange,
title,
@@ -42,7 +42,7 @@ export function TeamSeatsDialog({
onConfirm,
confirmButtonText,
showCostBreakdown = false,
}: TeamSeatsDialogProps) {
}: TeamSeatsProps) {
const [selectedSeats, setSelectedSeats] = useState(initialSeats)
useEffect(() => {
@@ -73,7 +73,7 @@ export function TeamSeatsDialog({
value={selectedSeats.toString()}
onValueChange={(value) => setSelectedSeats(Number.parseInt(value))}
>
<SelectTrigger id='seats'>
<SelectTrigger id='seats' className='rounded-[8px]'>
<SelectValue placeholder='Select number of seats' />
</SelectTrigger>
<SelectContent>

View File

@@ -9,15 +9,15 @@ import { useActiveOrganization } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { useOrganizationStore } from '@/stores/organization'
import type { MemberUsageData } from '@/stores/organization/types'
import { EditMemberLimitDialog } from './'
import { MemberLimit } from '../member-limit'
const logger = createLogger('TeamUsageOverview')
const logger = createLogger('TeamUsage')
interface TeamUsageOverviewProps {
interface TeamUsageProps {
hasAdminAccess: boolean
}
export function TeamUsageOverview({ hasAdminAccess }: TeamUsageOverviewProps) {
export function TeamUsage({ hasAdminAccess }: TeamUsageProps) {
const { data: activeOrg } = useActiveOrganization()
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [selectedMember, setSelectedMember] = useState<MemberUsageData | null>(null)
@@ -335,7 +335,7 @@ export function TeamUsageOverview({ hasAdminAccess }: TeamUsageOverviewProps) {
</Card>
{/* Edit Member Limit Dialog */}
<EditMemberLimitDialog
<MemberLimit
open={editDialogOpen}
onOpenChange={handleCloseEditDialog}
member={selectedMember}

View File

@@ -13,10 +13,8 @@ import { useSession } from '@/lib/auth-client'
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import {
TeamSeatsDialog,
TeamUsageOverview,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components'
import { generateSlug, useOrganizationStore } from '@/stores/organization'
import { useSubscriptionStore } from '@/stores/subscription/store'
import {
MemberInvitationCard,
NoOrganizationView,
@@ -24,10 +22,10 @@ import {
PendingInvitationsList,
RemoveMemberDialog,
TeamMembersList,
TeamSeats,
TeamSeatsOverview,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components'
import { generateSlug, useOrganizationStore } from '@/stores/organization'
import { useSubscriptionStore } from '@/stores/subscription/store'
TeamUsage,
} from './components'
const logger = createLogger('TeamManagement')
@@ -243,7 +241,7 @@ export function TeamManagement() {
if (isLoading && !activeOrganization && !(hasTeamPlan || hasEnterprisePlan)) {
return (
<div className='space-y-4 p-6'>
<div className='space-y-2 p-6'>
<Skeleton className='h-4 w-full' />
<Skeleton className='h-20 w-full' />
<Skeleton className='h-4 w-3/4' />
@@ -271,14 +269,14 @@ export function TeamManagement() {
}
return (
<div className='space-y-6 p-6'>
<div className='space-y-4 p-6'>
<div className='flex items-center justify-between'>
<h3 className='font-medium text-lg'>Team Management</h3>
<h3 className='font-medium text-sm'>Team Management</h3>
{organizations.length > 1 && (
<div className='flex items-center space-x-2'>
<select
className='rounded-md border border-input bg-background px-3 py-2 text-sm'
className='h-9 rounded-[8px] border border-input bg-background px-3 py-2 text-xs'
value={activeOrganization.id}
onChange={(e) => setActiveOrganization(e.target.value)}
>
@@ -293,7 +291,7 @@ export function TeamManagement() {
</div>
{error && (
<Alert variant='destructive'>
<Alert variant='destructive' className='rounded-[8px]'>
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
@@ -351,7 +349,7 @@ export function TeamManagement() {
</TabsContent>
<TabsContent value='usage' className='mt-4 space-y-4'>
<TeamUsageOverview hasAdminAccess={adminOrOwner} />
<TeamUsage hasAdminAccess={adminOrOwner} />
</TabsContent>
<TabsContent value='settings'>
@@ -373,10 +371,10 @@ export function TeamManagement() {
open={removeMemberDialog.open}
memberName={removeMemberDialog.memberName}
shouldReduceSeats={removeMemberDialog.shouldReduceSeats}
onOpenChange={(open) => {
onOpenChange={(open: boolean) => {
if (!open) setRemoveMemberDialog({ ...removeMemberDialog, open: false })
}}
onShouldReduceSeatsChange={(shouldReduce) =>
onShouldReduceSeatsChange={(shouldReduce: boolean) =>
setRemoveMemberDialog({
...removeMemberDialog,
shouldReduceSeats: shouldReduce,
@@ -393,11 +391,11 @@ export function TeamManagement() {
}
/>
<TeamSeatsDialog
<TeamSeats
open={isAddSeatDialogOpen}
onOpenChange={setIsAddSeatDialogOpen}
title='Add Team Seats'
description={`Update your team size. Each seat costs $${env.TEAM_TIER_COST_LIMIT}/month and gets $${env.TEAM_TIER_COST_LIMIT} of inference credits.`}
description={`Each seat costs $${env.TEAM_TIER_COST_LIMIT}/month and provides $${env.TEAM_TIER_COST_LIMIT} in monthly inference credits. Adjust the number of licensed seats for your team.`}
currentSeats={subscriptionData?.seats || 1}
initialSeats={newSeatCount}
isLoading={isUpdatingSeats}

View File

@@ -1,8 +1,7 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
import { getEnv, isTruthy } from '@/lib/env'
import { isHosted } from '@/lib/environment'
import { createLogger } from '@/lib/logs/console/logger'
@@ -48,6 +47,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const loadSettings = useGeneralStore((state) => state.loadSettings)
const { activeOrganization } = useOrganizationStore()
const hasLoadedInitialData = useRef(false)
const environmentCloseHandler = useRef<((open: boolean) => void) | null>(null)
useEffect(() => {
async function loadAllSettings() {
@@ -96,27 +96,25 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
const isSubscriptionEnabled = isBillingEnabled
// Handle dialog close - delegate to environment component if it's active
const handleDialogOpenChange = (newOpen: boolean) => {
if (!newOpen && activeSection === 'environment' && environmentCloseHandler.current) {
environmentCloseHandler.current(newOpen)
} else {
onOpenChange(newOpen)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex h-[70vh] flex-col gap-0 p-0 sm:max-w-[800px]' hideCloseButton>
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogContent className='flex h-[70vh] flex-col gap-0 p-0 sm:max-w-[840px]'>
<DialogHeader className='border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<DialogTitle className='font-medium text-lg'>Settings</DialogTitle>
<Button
variant='ghost'
size='icon'
className='h-8 w-8 p-0'
onClick={() => onOpenChange(false)}
>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
</div>
<DialogTitle className='font-medium text-lg'>Settings</DialogTitle>
</DialogHeader>
<div className='flex min-h-0 flex-1'>
{/* Navigation Sidebar */}
<div className='w-[200px] border-r'>
<div className='w-[180px]'>
<SettingsNavigation
activeSection={activeSection}
onSectionChange={setActiveSection}
@@ -130,7 +128,12 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
<General />
</div>
<div className={cn('h-full', activeSection === 'environment' ? 'block' : 'hidden')}>
<EnvironmentVariables onOpenChange={onOpenChange} />
<EnvironmentVariables
onOpenChange={onOpenChange}
registerCloseHandler={(handler) => {
environmentCloseHandler.current = handler
}}
/>
</div>
<div className={cn('h-full', activeSection === 'account' ? 'block' : 'hidden')}>
<Account onOpenChange={onOpenChange} />

View File

@@ -67,7 +67,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
: 'free'
// Determine badge to show
const showAddBadge = planType !== 'free' && usage.percentUsed >= 85
const showAddBadge = planType !== 'free' && usage.percentUsed >= 50
const badgeText = planType === 'free' ? 'Upgrade' : 'Add'
const badgeType = planType === 'free' ? 'upgrade' : 'add'

View File

@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
'flex h-10 w-full items-center justify-between rounded-[8px] border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
@@ -116,7 +116,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}

View File

@@ -10,7 +10,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
@@ -18,7 +18,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>

View File

@@ -273,6 +273,27 @@ export async function updateUserUsageLimit(
}
}
// Get current usage to validate against
const userStatsRecord = await db
.select()
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (userStatsRecord.length > 0) {
const currentUsage = Number.parseFloat(
userStatsRecord[0].currentPeriodCost?.toString() || userStatsRecord[0].totalCost.toString()
)
// Validate new limit is not below current usage
if (newLimit < currentUsage) {
return {
success: false,
error: `Usage limit cannot be below current usage of $${currentUsage.toFixed(2)}`,
}
}
}
// Update the usage limit
await db
.update(userStats)