From 72e3efa875facee18cb7a1fe1d4899600a31835c Mon Sep 17 00:00:00 2001 From: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com> Date: Mon, 18 Aug 2025 20:57:29 -0700 Subject: [PATCH] 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 --- apps/sim/app/api/users/me/profile/route.ts | 120 +++ apps/sim/app/globals.css | 9 +- .../components/search-modal/search-modal.tsx | 2 +- .../components/account/account.tsx | 558 +++++++------ .../components/api-keys/api-keys.tsx | 400 +++++----- .../components/copilot/copilot.tsx | 406 +++++----- .../components/credentials/credentials.tsx | 324 ++++---- .../components/environment/environment.tsx | 167 ++-- .../components/general/general.tsx | 298 ++++--- .../components/privacy/privacy.tsx | 102 +-- .../settings-navigation.tsx | 70 +- .../components/billing-summary.tsx | 144 ---- .../cancel-subscription.tsx | 119 +-- .../components/cancel-subscription/index.ts | 1 + .../subscription/components/index.ts | 8 +- .../components/plan-card/index.ts | 1 + .../components/plan-card/plan-card.tsx | 123 +++ .../components/usage-limit-editor.tsx | 91 --- .../components/usage-limit/index.ts | 2 + .../components/usage-limit/usage-limit.tsx | 209 +++++ .../components/subscription/plan-configs.ts | 35 + .../subscription/subscription-permissions.ts | 69 ++ .../components/subscription/subscription.tsx | 752 ++++++++++-------- .../team-management/components/index.ts | 3 + .../member-invitation-card/index.ts | 1 + .../member-invitation-card.tsx | 36 +- .../components/member-limit/index.ts | 1 + .../components/member-limit/member-limit.tsx} | 6 +- .../components/no-organization-view/index.ts | 1 + .../no-organization-view.tsx | 22 +- .../organization-creation-dialog/index.ts | 1 + .../organization-creation-dialog.tsx | 21 +- .../organization-settings-tab/index.ts | 1 + .../organization-settings-tab.tsx | 24 +- .../pending-invitations-list/index.ts | 1 + .../pending-invitations-list.tsx | 15 +- .../components/remove-member-dialog/index.ts | 1 + .../remove-member-dialog.tsx | 12 +- .../components/team-members-list/index.ts | 1 + .../team-members-list.tsx | 21 +- .../components/team-seats-overview/index.ts | 1 + .../team-seats-overview.tsx | 42 +- .../components/team-seats/index.ts | 1 + .../components/team-seats/team-seats.tsx} | 8 +- .../components/team-usage/index.ts | 1 + .../components/team-usage/team-usage.tsx} | 10 +- .../team-management/team-management.tsx | 32 +- .../settings-modal/settings-modal.tsx | 39 +- .../usage-indicator/usage-indicator.tsx | 2 +- apps/sim/components/ui/select.tsx | 4 +- apps/sim/components/ui/switch.tsx | 4 +- apps/sim/lib/billing/core/usage.ts | 21 + 52 files changed, 2501 insertions(+), 1842 deletions(-) create mode 100644 apps/sim/app/api/users/me/profile/route.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/billing-summary.tsx rename apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/{ => cancel-subscription}/cancel-subscription.tsx (60%) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/plan-card/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/plan-card/plan-card.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit-editor.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription-permissions.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/index.ts rename apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/{ => member-invitation-card}/member-invitation-card.tsx (88%) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/index.ts rename apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/{subscription/components/edit-member-limit-dialog.tsx => team-management/components/member-limit/member-limit.tsx} (98%) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/index.ts rename apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/{ => no-organization-view}/no-organization-view.tsx (85%) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-creation-dialog/index.ts rename apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/{ => organization-creation-dialog}/organization-creation-dialog.tsx (82%) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/organization-settings-tab/index.ts rename apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/{ => organization-settings-tab}/organization-settings-tab.tsx (87%) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/pending-invitations-list/index.ts rename apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/{ => pending-invitations-list}/pending-invitations-list.tsx (73%) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/remove-member-dialog/index.ts rename apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/{ => remove-member-dialog}/remove-member-dialog.tsx (82%) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-members-list/index.ts rename apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/{ => team-members-list}/team-members-list.tsx (70%) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats-overview/index.ts rename apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/{ => team-seats-overview}/team-seats-overview.tsx (78%) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats/index.ts rename apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/{subscription/components/team-seats-dialog.tsx => team-management/components/team-seats/team-seats.tsx} (96%) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-usage/index.ts rename apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/{subscription/components/team-usage-overview.tsx => team-management/components/team-usage/team-usage.tsx} (98%) diff --git a/apps/sim/app/api/users/me/profile/route.ts b/apps/sim/app/api/users/me/profile/route.ts new file mode 100644 index 0000000000..e34e01d979 --- /dev/null +++ b/apps/sim/app/api/users/me/profile/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/globals.css b/apps/sim/app/globals.css index 24dbca36cb..62242cc734 100644 --- a/apps/sim/app/globals.css +++ b/apps/sim/app/globals.css @@ -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; } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/search-modal.tsx index d89c2ec824..7f36a3bcf0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/search-modal/search-modal.tsx @@ -586,7 +586,7 @@ export function SearchModal({ className='bg-white/50 dark:bg-black/50' style={{ backdropFilter: 'blur(1.5px)' }} /> - + Search diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/account/account.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/account/account.tsx index 489494abea..77b37c4f5a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/account/account.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/account/account.tsx @@ -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({ - 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(null) - // Mock accounts for the multi-account UI - const [accounts, setAccounts] = useState([]) - 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(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 = () => ( -
-
-
-
-
-
-
-
-
-
-
-
- ) - return ( -
-
-

Account

-
+
+
+ {isLoadingProfile || isPending ? ( + <> + {/* User Info Section Skeleton */} +
+ {/* User Avatar Skeleton */} + - {/* Account Dropdown Component */} -
-
- {isPending || isLoadingUserData ? ( - - ) : ( - - -
-
-
- {userData.isLoggedIn ? ( -
- -
- ) : ( -
- -
- )} - {userData.isLoggedIn && accounts.length > 1 && ( -
- {accounts.length} -
- )} -
-
-

- {userData.isLoggedIn ? activeAccount?.name : 'Sign in'} -

-

- {userData.isLoggedIn ? activeAccount?.email : 'Click to sign in'} -

-
-
- + + +
+
+ + {/* Name Field Skeleton */} +
+ +
+ + +
+
+ + {/* Email Field Skeleton */} +
+ + +
+ + {/* Password Field Skeleton */} +
+ +
+ + +
+
+ + {/* Sign Out Button Skeleton */} +
+ +
+ + ) : ( + <> + {/* User Info Section */} +
+ {/* User Avatar */} +
+ {userImage ? ( + {name -
- - - {userData.isLoggedIn ? ( - <> - {accounts.length > 1 && ( - <> -
- Switch Account -
- {accounts.map((account) => ( - -
- -
-
- {account.name} - {account.email} -
-
- ))} - - - )} - { - setResetPasswordDialogOpen(true) - setOpen(false) - }} - > - - Reset Password - - - - - Sign Out - - ) : ( - <> - - - Sign in - - + )} -
- - )} -
-
+
- {/* Reset Password Dialog */} - - - - Reset Password - - - - + {/* User Details */} +
+

{name}

+

{email}

+
+
+ + {/* Name Field */} +
+ + {isEditingName ? ( + 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' + /> + ) : ( +
+ {name} + +
+ )} +
+ + {/* Email Field - Read Only */} +
+ +

{email}

+
+ + {/* Password Field */} +
+ +
+ •••••••• + +
+
+ + {/* Sign Out Button */} +
+ +
+ + )} +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx index b8a45baf56..e149f47553 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/api-keys.tsx @@ -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(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 ( -
-
-

API Keys

- +
+ {/* Fixed Header */} +
+ {/* Search Input */} + {isLoading ? ( + + ) : ( +
+ + 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' + /> +
+ )}
-

- API keys allow you to authenticate and trigger workflows. Keep your API keys secure. They - have access to your account and workflows. -

- - {isLoading ? ( -
- - -
- ) : apiKeys.length === 0 ? ( -
-
-
- + {/* Scrollable Content */} +
+
+ {isLoading ? ( +
+ + +
-

No API keys yet

-

- You don't have any API keys yet. Create one to get started with the Sim SDK. -

- -
-
- ) : ( -
- {apiKeys.map((key) => ( - -
-
-

{key.name}

-
-

- Created: {formatDate(key.createdAt)} • Last used: {formatDate(key.lastUsed)} -

-
- •••••{key.key.slice(-6)} + ) : apiKeys.length === 0 ? ( +
+ Click "Create Key" below to get started +
+ ) : ( +
+ {filteredApiKeys.map((key) => ( +
+ +
+
+
+ + •••••{key.key.slice(-6)} + +
+

+ Last used: {formatDate(key.lastUsed)} +

+ +
- -
- - ))} + ))} + {/* Show message when search has no results but there are keys */} + {searchTerm.trim() && filteredApiKeys.length === 0 && apiKeys.length > 0 && ( +
+ No API keys found matching "{searchTerm}" +
+ )} +
+ )}
- )} +
+ + {/* Footer */} +
+
+ {isLoading ? ( + <> + +
+ + ) : ( + <> + +
Keep your API keys secure
+ + )} +
+
{/* Create API Key Dialog */} - - - - Create new API key - - Name your API key to help you identify it later. This key will have access to your - account and workflows. - - -
-
- - setNewKeyName(e.target.value)} - className='focus-visible:ring-primary' - /> -
+ + + + Create new API key + + 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. + + + +
+

+ Enter a name for your API key to help you identify it later. +

+ setNewKeyName(e.target.value)} + placeholder='e.g., Development, Production' + className='h-9 rounded-[8px]' + autoFocus + />
- - - - - -
+ + { + 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 + + + + {/* New API Key Dialog */} - { setShowNewKeyDialog(open) - if (!open) setNewKey(null) + if (!open) { + setNewKey(null) + setCopySuccess(false) + } }} > - - - Your API key has been created - - This is the only time you will see your API key. Copy it now and store it securely. - - + + + Your API key has been created + + This is the only time you will see your API key.{' '} + Copy it now and store it securely. + + + {newKey && ( -
-
- -
- - -
-

- For security, we don't store the complete key. You won't be able to view - it again. -

+
+
+ + {newKey.key} +
+
)} - - - - -
+ + {/* Delete Confirmation Dialog */} - + - Delete API Key + Delete API key? - {deleteKey && ( - <> - Are you sure you want to delete the API key{' '} - {deleteKey.name}? 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.{' '} + This action cannot be undone. - - setDeleteKey(null)}>Cancel + + {deleteKey && ( +
+

+ Enter the API key name {deleteKey.name} to + confirm. +

+ setDeleteConfirmationName(e.target.value)} + placeholder='Type key name to confirm' + className='h-9 rounded-[8px]' + autoFocus + /> +
+ )} + + + { + setDeleteKey(null) + setDeleteConfirmationName('') + }} + > + Cancel + { + 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 @@ -354,16 +392,18 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) { ) } -function KeySkeleton() { +// Loading skeleton for API keys +function ApiKeySkeleton() { return ( - -
-
- - +
+ {/* API key name */} +
+
+ {/* Key preview */} + {/* Last used */}
- + {/* Delete button */}
- +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx index afd585ff70..32315f6a44 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx @@ -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([]) - const [isLoading, setIsLoading] = useState(false) + const [isLoading, setIsLoading] = useState(true) const [visible, setVisible] = useState>({}) + const [searchTerm, setSearchTerm] = useState('') // Create flow state const [showNewKeyDialog, setShowNewKeyDialog] = useState(false) @@ -49,13 +43,16 @@ export function Copilot() { const [deleteKey, setDeleteKey] = useState(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 ( -
-

Copilot API Keys

- -

- Copilot API keys let you authenticate requests to the Copilot endpoints. Keep keys secret - and rotate them regularly. -

-

- For external deployments, set the COPILOT_API_KEY{' '} - environment variable on that instance to one of the keys generated here. -

- - {isFetching ? ( -
- -
-
- - -
- -
-
- -
-
- - -
- -
-
-
- ) : !hasKeys ? ( -
-
-
- -
-

No Copilot keys yet

-

- Generate a Copilot API key to authenticate requests to the Copilot SDK and methods. -

- +
+ {/* Fixed Header */} +
+ {/* Search Input */} + {isLoading ? ( + + ) : ( +
+ + 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' + />
-
- ) : ( -
- {keys.map((k) => { - const isVisible = !!visible[k.id] - const value = maskedValue(k.apiKey, isVisible) - return ( - -
-
-
{value}
-
-
- - - - - - {isVisible ? 'Hide' : 'Reveal'} - - + )} +
- - - - - - Copy - - + {/* Scrollable Content */} +
+
+ {isLoading ? ( +
+ + + +
+ ) : keys.length === 0 ? ( +
+ Click "Generate Key" below to get started +
+ ) : ( +
+ {filteredKeys.map((k) => { + const isVisible = !!visible[k.id] + const value = maskedValue(k.apiKey, isVisible) + return ( +
+ +
+
+
+ {value} +
+
+ + + + + + {isVisible ? 'Hide' : 'Reveal'} + + - - - - - - Delete - - + + + + + + Copy + + +
+
+ + +
+ ) + })} + {/* Show message when search has no results but there are keys */} + {searchTerm.trim() && filteredKeys.length === 0 && keys.length > 0 && ( +
+ No API keys found matching "{searchTerm}"
- - ) - })} + )} +
+ )}
- )} +
- {/* New Key Dialog */} - +
+ {isLoading ? ( + <> + +
+ + ) : ( + <> + +
Keep your API keys secure
+ + )} +
+
+ + {/* New API Key Dialog */} + { setShowNewKeyDialog(open) - if (!open) setNewKey(null) + if (!open) { + setNewKey(null) + setNewKeyCopySuccess(false) + } }} > - - - Your Copilot API key has been created - - This is the only time you will see the full key. Copy it now and store it securely. - - + + + New Copilot API Key + + Copy it now and store it securely. + + + {newKey && ( -
-
- -
- - -
-

- For security, we don't store the complete key. You won't be able to view it again. -

+
+
+ + {newKey.apiKey} +
+
)} - - - - -
+ + {/* Delete Confirmation Dialog */} - + - Delete Copilot API Key + Delete Copilot API key? - {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.{' '} + This action cannot be undone. - - setDeleteKey(null)}>Cancel + + + setDeleteKey(null)} + > + Cancel + { 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 @@ -362,3 +353,22 @@ export function Copilot() {
) } + +// Loading skeleton for Copilot API keys +function CopilotKeySkeleton() { + return ( +
+ {/* API key label */} +
+
+ {/* Key preview */} +
+ {/* Show/Hide button */} + {/* Copy button */} +
+
+ {/* Delete button */} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx index 097c0d510d..b8fd8e59f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx @@ -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 ( -
-
-
-

Credentials

- - {/* Search Input */} -
- - setSearchTerm(e.target.value)} - className='h-9 pl-9 text-sm' - /> -
+
+ {/* Search Input */} +
+
+ + 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' + />
-

- Connect your accounts to use tools that require authentication. -

- {/* Success message */} - {authSuccess && ( -
-
-
- + {/* Scrollable Content */} +
+
+ {/* Success message */} + {authSuccess && ( +
+
+
+ +
+
+

+ Account connected successfully! +

+
+
-
-

Account connected successfully!

+ )} + + {/* Pending service message - only shown when coming from OAuth required modal */} + {pendingService && showActionRequired && ( +
+
+ +
+
+

+ Action Required: Please connect + your account to enable the requested features. The required service is highlighted + below. +

+ +
-
-
- )} + )} - {/* Pending service message - only shown when coming from OAuth required modal */} - {pendingService && showActionRequired && ( -
-
- -
-
-

- Action Required: Please connect your - account to enable the requested features. The required service is highlighted below. -

- -
-
- )} - - {/* Loading state */} - {isLoading ? ( -
- - - - -
- ) : ( -
- {/* Group services by provider */} - {Object.entries(filteredGroupedServices).map(([providerKey, providerServices]) => ( -
-

- {OAUTH_PROVIDERS[providerKey]?.name || 'Other Services'} -

-
- {providerServices.map((service) => ( - -
-
-
+ {/* Loading state */} + {isLoading ? ( +
+ {/* Google section - 5 blocks */} +
+ {/* "GOOGLE" label */} + + + + + +
+ {/* Microsoft section - 6 blocks */} +
+ {/* "MICROSOFT" label */} + + + + + + +
+
+ ) : ( +
+ {/* Services list */} + {Object.entries(filteredGroupedServices).map(([providerKey, providerServices]) => ( +
+ + {providerServices.map((service) => ( +
+
+
{typeof service.icon === 'function' ? service.icon({ className: 'h-5 w-5' }) : service.icon}
-
-
-

{service.name}

-

+

+
+ {service.name} +
+ {service.accounts && service.accounts.length > 0 ? ( +

+ {service.accounts.map((a) => a.name).join(', ')} +

+ ) : ( +

{service.description}

-
- {service.accounts && service.accounts.length > 0 && ( -
- {service.accounts.map((account) => ( -
-
-
- -
- {account.name} -
- -
- ))} - {/* */} -
)}
- {!service.accounts?.length && ( -
- -
+ {service.accounts && service.accounts.length > 0 ? ( + + ) : ( + )}
- - ))} -
-
- ))} + ))} +
+ ))} - {/* Show message when search has no results */} - {searchTerm.trim() && Object.keys(filteredGroupedServices).length === 0 && ( -
- No services found matching "{searchTerm}" + {/* Show message when search has no results */} + {searchTerm.trim() && Object.keys(filteredGroupedServices).length === 0 && ( +
+ No services found matching "{searchTerm}" +
+ )}
)}
- )} +
) } @@ -487,17 +461,15 @@ export function Credentials({ onOpenChange }: CredentialsProps) { // Loading skeleton for connections function ConnectionSkeleton() { return ( - -
-
- -
- - -
+
+
+ +
+ +
-
- + +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx index 872fd8fe8c..f7c743902c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/environment/environment.tsx @@ -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([]) const [searchTerm, setSearchTerm] = useState('') const [focusedValueIndex, setFocusedValueIndex] = useState(null) const [showUnsavedChanges, setShowUnsavedChanges] = useState(false) + const [shouldScrollToBottom, setShouldScrollToBottom] = useState(false) const scrollContainerRef = useRef(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' /> 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' > × @@ -257,64 +277,82 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps ) return ( -
+
{/* Fixed Header */} -
-
-

Environment Variables

- - {/* Search Input */} -
- +
+ {/* Search Input */} + {isLoading ? ( + + ) : ( +
+ 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' />
-
- -
- - -
-
+ )}
{/* Scrollable Content */}
-
- {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 && ( -
- No environment variables found matching "{searchTerm}" -
+
+ {isLoading ? ( + <> + {/* Show 3 skeleton rows */} + {[1, 2, 3].map((index) => ( +
+ + + +
+ ))} + + ) : ( + <> + {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 && ( +
+ No environment variables found matching "{searchTerm}" +
+ )} + )}
- {/* Fixed Footer */} -
-
- + {/* Footer */} +
+
+ {isLoading ? ( + <> + + + + ) : ( + <> + -
- - -
+ + + )}
@@ -326,9 +364,16 @@ export function EnvironmentVariables({ onOpenChange }: EnvironmentVariablesProps You have unsaved changes. Do you want to save them before closing? - - Discard Changes - Save Changes + + + Discard Changes + + + Save Changes + diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx index c6600b18ed..7c4e34dd76 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/general/general.tsx @@ -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 ( -
- {error && ( - - - - Failed to load settings: {error} - - - - )} +
+
+ {isLoading ? ( + <> + {/* Theme setting with skeleton value */} +
+
+ +
+ +
-
-

General Settings

-
- {isLoading ? ( - <> - - - - - - ) : ( - <> -
-
- -
- -
-
-
- - - - - - -

{TOOLTIPS.autoConnect}

-
-
-
- + {/* Auto-connect setting with skeleton value */} +
+
+ + + + + + +

{TOOLTIPS.autoConnect}

+
+
+ +
-
-
- - - - - - -

{TOOLTIPS.consoleExpandedByDefault}

-
-
-
- + {/* Console expanded setting with skeleton value */} +
+
+ + + + + + +

{TOOLTIPS.consoleExpandedByDefault}

+
+
- - )} -
+ +
+ + ) : ( + <> +
+
+ +
+ +
+
+
+ + + + + + +

{TOOLTIPS.autoConnect}

+
+
+
+ +
+ +
+
+ + + + + + +

{TOOLTIPS.consoleExpandedByDefault}

+
+
+
+ +
+ + )}
) } -const SettingRowSkeleton = () => ( -
+const SettingRowSkeleton = ({ + hasInfoButton = false, + isSwitch = false, +}: { + hasInfoButton?: boolean + isSwitch?: boolean +}) => ( +
+ {hasInfoButton && }
- + {isSwitch ? ( + + ) : ( + + )}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/privacy/privacy.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/privacy/privacy.tsx index 8d7bba04db..5b2d47b368 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/privacy/privacy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/privacy/privacy.tsx @@ -45,61 +45,69 @@ export function Privacy() { } return ( -
-
-

Privacy Settings

-
- {isLoading ? ( - - ) : ( -
-
- - - - - - -

{TOOLTIPS.telemetry}

-
-
-
- +
+
+ {isLoading ? ( + + ) : ( +
+
+ + + + + + +

{TOOLTIPS.telemetry}

+
+
- )} -
-
+ +
+ )} -
-

- 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. -

+
+

+ 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. +

+
) } -const SettingRowSkeleton = () => ( -
+const SettingRowSkeleton = ({ + hasInfoButton = false, + isSwitch = false, +}: { + hasInfoButton?: boolean + isSwitch?: boolean +}) => ( +
- + + {hasInfoButton && }
- + {isSwitch ? ( + + ) : ( + + )}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx index 851a1d7d70..1100f90610 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/settings-navigation/settings-navigation.tsx @@ -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 ( -
+
{navigationItems.map((item) => ( - +
+ +
))}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/billing-summary.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/billing-summary.tsx deleted file mode 100644 index 3d223ddafd..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/billing-summary.tsx +++ /dev/null @@ -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(null) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(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 ( - - - Limit Exceeded - - ) - } - if (billingSummary.isWarning) { - return ( - - - Approaching Limit - - ) - } - return null - } - - const formatCurrency = (amount: number) => `$${amount.toFixed(2)}` - - if (isLoading || error || !billingSummary) { - return null - } - - return ( -
- {/* Status Badge */} - {getStatusBadge()} - - {/* Billing Details */} - {showDetails && ( -
-
- Plan minimum: - {formatCurrency(billingSummary.planMinimum)} -
-
- Projected charge: - {formatCurrency(billingSummary.projectedCharge)} -
- {billingSummary.organizationData && ( -
- Team seats: - {billingSummary.organizationData.seatCount} -
- )} -
- )} -
- ) -} - -export type { BillingSummaryData } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx similarity index 60% rename from apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx index e31061f5e5..ee24a02644 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx @@ -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 ( <> -
-
-
- Cancel Subscription -

- You'll keep access until {formatDate(periodEndDate)} -

-
- +
+
+ Manage Subscription +

+ You'll keep access until {formatDate(periodEndDate)} +

- - {error && ( - - {error} - - )} +
- - - - Cancel {subscription.plan} subscription? - + + + + Cancel {subscription.plan} subscription? + You'll be redirected to Stripe to manage your subscription. You'll keep access until{' '} {formatDate(periodEndDate)}, then downgrade to free plan. - - + + -
-
-
    +
    +
    +
    • • Keep all features until {formatDate(periodEndDate)}
    • • No more charges
    • • Data preserved
    • @@ -161,16 +172,24 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
    - - - - - -
+ + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/index.ts new file mode 100644 index 0000000000..a7d0def45c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/index.ts @@ -0,0 +1 @@ +export { CancelSubscription } from './cancel-subscription' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/index.ts index 17c5db87cf..73b2e8bc7d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/index.ts @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/plan-card/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/plan-card/index.ts new file mode 100644 index 0000000000..5685e9ac3b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/plan-card/index.ts @@ -0,0 +1 @@ +export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/plan-card/plan-card.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/plan-card/plan-card.tsx new file mode 100644 index 0000000000..a8599da299 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/plan-card/plan-card.tsx @@ -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 ( + <> + {price} + {priceSubtext && ( + {priceSubtext} + )} + + ) + } + return price + } + + const renderFeatures = () => { + if (isHorizontal) { + return ( +
+ {features.map((feature, index) => ( +
+ + {feature.text} + {index < features.length - 1 && ( + + ))} +
+ ) + } + + return ( +
    + {features.map((feature, index) => ( +
  • +
  • + ))} +
+ ) + } + + return ( +
+
+

{name}

+
{renderPrice()}
+ {isHorizontal && renderFeatures()} +
+ + {!isHorizontal && renderFeatures()} + +
+ +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit-editor.tsx deleted file mode 100644 index c356c02c95..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit-editor.tsx +++ /dev/null @@ -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 ( -
- $ - {canEdit ? ( - 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' - /> - ) : ( - {currentLimit} - )} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit/index.ts new file mode 100644 index 0000000000..d09782c8e6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit/index.ts @@ -0,0 +1,2 @@ +export type { UsageLimitRef } from './usage-limit' +export { UsageLimit } from './usage-limit' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx new file mode 100644 index 0000000000..a3b8fe1be7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/usage-limit/usage-limit.tsx @@ -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( + ({ 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(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 ( +
+ {isEditing ? ( + <> + $ + 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' + /> + + ) : ( + ${currentLimit} + )} + {canEdit && ( + + )} +
+ ) + } +) + +UsageLimit.displayName = 'UsageLimit' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs.ts new file mode 100644 index 0000000000..c4bb43bd2b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs.ts @@ -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' }, +] diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription-permissions.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription-permissions.ts new file mode 100644 index 0000000000..cf945ca47c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription-permissions.ts @@ -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 +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx index 3c47e7f55c..51a34cc9bb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx @@ -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 ( +
+
+ {/* Current Plan skeleton - matches usage indicator style */} +
+
+
+
+
+ + +
+
+ + / + +
+
+ +
+
+
+ + {/* Plan cards skeleton */} +
+ {/* Pro and Team skeleton grid */} +
+ {/* Pro Plan Card Skeleton */} +
+
+ +
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + {/* Team Plan Card Skeleton */} +
+
+ +
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + {/* Enterprise skeleton - horizontal layout */} +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ +
+
+
+
+ ) +} + +// 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(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 ( + handleUpgrade('pro')} + isError={upgradeError === 'pro'} + layout={layout} + /> + ) + + case 'team': + return ( + handleUpgrade('team')} + isError={upgradeError === 'team'} + layout={layout} + /> + ) + + case 'enterprise': + return ( + Custom} + 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 ( -
- - - -
- ) - } - - if (error) { - return ( -
- - - Error - {error} - -
- ) + return } return ( -
-
- {/* Current Plan & Usage Overview */} -
-
-

Current Plan

-
- - {subscription.plan} Plan - - {!subscription.isFree && } -
-
+
+
+ {/* Current Plan & Usage Overview - Styled like usage-indicator */} +
+
+
+ {/* Plan and usage info */} +
+
+ + {formatPlanName(subscription.plan)} + + {showBadge && ( + { + e.stopPropagation() + handleBadgeClick() + }} + > + {badgeText} + + )} + {/* Team seats info for admins */} + {permissions.canManageTeam && ( + + ({organizationBillingData?.totalSeats || subscription.seats || 1} seats) + + )} +
+
+ ${usage.current.toFixed(2)} + / + {!subscription.isFree && + (permissions.canEditUsageLimit || + permissions.showTeamMemberView || + subscription.isEnterprise) ? ( + + ) : ( + ${usage.limit} + )} +
+
-
- - ${usage.current.toFixed(2)} / ${usage.limit} - -
- - {usage.percentUsed}% used this period - + {/* Progress Bar */} +
- {/* Usage Alerts */} - {billingStatus === 'exceeded' && ( - - - Usage Limit Exceeded - - You've exceeded your usage limit of ${usage.limit}. Please upgrade your plan or - increase your limit. - - - )} - - {billingStatus === 'warning' && ( - - - Approaching Usage Limit - - You've used {usage.percentUsed}% of your ${usage.limit} limit. Consider upgrading or - increasing your limit. - - - )} - - {/* Usage Limit Editor */} -
-
- - {subscription.isTeam ? 'Individual Limit' : 'Monthly Limit'} - - {isLoadingOrgBilling ? ( - - ) : ( - - )} -
- {subscription.isFree && ( -

- Upgrade to Pro ($20 minimum) or Team ($40 minimum) to customize your usage limit. + {/* Team Member Notice */} + {permissions.showTeamMemberView && ( +

+

+ Contact your team admin to increase limits

- )} - {subscription.isPro && ( -

- Pro plan minimum: $20. You can set your individual limit higher. -

- )} - {subscription.isTeam && !isTeamAdmin && ( -

- Contact your team owner to adjust your limit. Team plan minimum: $40. -

- )} - {subscription.isTeam && isTeamAdmin && ( -

- Team plan minimum: $40 per member. Manage team member limits in the Team tab. -

- )} -
- - {/* Team Management */} - {subscription.isTeam && ( -
- {isLoadingOrgBilling ? ( - - -
-
- - -
- -
-
- -
-
- - -
-
- - -
-
- -
-
- ) : shouldShowOrgBilling ? ( - - -
- - - Team Plan - -
-
- - {/* Team Summary */} -
-
- Licensed Seats - - {organizationBillingData.totalSeats} seats - -
-
- Monthly Bill - - ${organizationBillingData.totalSeats * 40} - -
-
- Current Usage - - ${organizationBillingData.totalCurrentUsage?.toFixed(2) || 0} - -
-
- - {/* Simple Explanation */} -
-

- 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. -

-
-
-
- ) : ( - - - - - Team Plan - - - -
-
- Your monthly allowance - ${usage.limit} -
-

- Contact your team owner to adjust your limit -

-
-
-
- )}
)} - {/* Upgrade Actions */} - {subscription.isFree && ( -
- - -
-

- Need a custom plan?{' '} - - Contact us - {' '} - for Enterprise pricing -

-
-
- )} + {/* Upgrade Plans */} + {permissions.showUpgradePlans && ( +
+ {/* Render plans based on what should be visible */} + {(() => { + const totalPlans = visiblePlans.length + const hasEnterprise = visiblePlans.includes('enterprise') - {subscription.isPro && !subscription.isTeam && ( - + // Special handling for Pro users - show team and enterprise side by side + if (subscription.isPro && totalPlans === 2) { + return ( +
+ {visiblePlans.map((plan) => renderPlanCard(plan, 'vertical'))} +
+ ) + } + + // 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 && ( +
+ {otherPlans.map((plan) => renderPlanCard(plan, 'vertical'))} +
+ )} + + {/* Enterprise plan */} + {hasEnterprise && renderPlanCard('enterprise', enterpriseLayout)} + + ) + })()} +
)} {subscription.isEnterprise && ( -
-

- Enterprise plan - Contact support for changes +

+

+ Contact enterprise for support usage limit changes

)} {/* Cancel Subscription */} - - - {/* Team Seats Dialog */} - + {permissions.canCancelSubscription && ( +
+ +
+ )}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/index.ts index 3a6d760567..91e4106026 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/index.ts @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/index.ts new file mode 100644 index 0000000000..2a0bb699f6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/index.ts @@ -0,0 +1 @@ +export { MemberInvitationCard } from './member-invitation-card' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx similarity index 88% rename from apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx index 3ecf49d7ca..a30e59dddd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-invitation-card/member-invitation-card.tsx @@ -98,14 +98,14 @@ export function MemberInvitationCard({ const selectedCount = selectedWorkspaces.length return ( - - - Invite Team Members + + + Invite Team Members Add new members to your team and optionally give them access to specific workspaces - +
{showWorkspaceInvite ? 'Hide' : 'Add'} Workspaces {selectedCount > 0 && ( - + {selectedCount} )} @@ -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 ? : } Invite @@ -153,8 +156,8 @@ export function MemberInvitationCard({
-
Workspace Access
- +
Workspace Access
+ Optional
@@ -174,7 +177,7 @@ export function MemberInvitationCard({

) : ( -
+
{userWorkspaces.map((workspace) => { const isSelected = selectedWorkspaces.some((w) => w.workspaceId === workspace.id) const selectedWorkspace = selectedWorkspaces.find( @@ -185,13 +188,13 @@ export function MemberInvitationCard({
-
+
{workspace.isOwner && ( - + Owner )} @@ -242,7 +248,7 @@ export function MemberInvitationCard({ )} {inviteSuccess && ( - + Invitation sent successfully diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/index.ts new file mode 100644 index 0000000000..f09d182a32 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/index.ts @@ -0,0 +1 @@ +export { MemberLimit } from './member-limit' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/edit-member-limit-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/member-limit.tsx similarity index 98% rename from apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/edit-member-limit-dialog.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/member-limit.tsx index 19470105e5..d78038fc67 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/edit-member-limit-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/member-limit/member-limit.tsx @@ -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(null) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/index.ts new file mode 100644 index 0000000000..2d540c4f7e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/index.ts @@ -0,0 +1 @@ +export { NoOrganizationView } from './no-organization-view' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx similarity index 85% rename from apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view.tsx rename to apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx index 6ec678b5ad..614b25b63a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/no-organization-view/no-organization-view.tsx @@ -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 ( -
+
-

Create Your Team Workspace

+

Create Your Team Workspace

-
+

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({

-