mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
120
apps/sim/app/api/users/me/profile/route.ts
Normal file
120
apps/sim/app/api/users/me/profile/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'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'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-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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CancelSubscription } from './cancel-subscription'
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { UsageLimitRef } from './usage-limit'
|
||||
export { UsageLimit } from './usage-limit'
|
||||
@@ -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'
|
||||
@@ -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' },
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { MemberInvitationCard } from './member-invitation-card'
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export { MemberLimit } from './member-limit'
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { NoOrganizationView } from './no-organization-view'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { OrganizationCreationDialog } from './organization-creation-dialog'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { OrganizationSettingsTab } from './organization-settings-tab'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { PendingInvitationsList } from './pending-invitations-list'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { RemoveMemberDialog } from './remove-member-dialog'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { TeamMembersList } from './team-members-list'
|
||||
@@ -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>
|
||||
)}
|
||||
@@ -0,0 +1 @@
|
||||
export { TeamSeatsOverview } from './team-seats-overview'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { TeamSeats } from './team-seats'
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { TeamUsage } from './team-usage'
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user