diff --git a/.gitignore b/.gitignore index fce808e569..cb32b4f5ad 100644 --- a/.gitignore +++ b/.gitignore @@ -20,5 +20,5 @@ Thumbs.db .vercel # docker -.docker/data -.docker/logs +**/.docker/data +**/.docker/logs diff --git a/apps/web/package.json b/apps/web/package.json index 5e67cdca58..0b8c576aa3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,7 +32,7 @@ "@radix-ui/react-toast": "^1.2.14", "@radix-ui/react-tooltip": "^1.2.7", "@roo-code-cloud/db": "workspace:^", - "@roo-code/types": "^1.26.0", + "@roo-code/types": "^1.28.0", "@sentry/nextjs": "^9.23.0", "@t3-oss/env-nextjs": "^0.13.6", "@tailwindcss/postcss": "^4.1.8", diff --git a/apps/web/src/actions/agents.ts b/apps/web/src/actions/agents.ts index d5123fc9fa..ab1587a25a 100644 --- a/apps/web/src/actions/agents.ts +++ b/apps/web/src/actions/agents.ts @@ -29,6 +29,14 @@ export async function createAgent( const { userId, orgId, orgRole } = authResult; + // Agents are only available for organizations, not personal accounts + if (!orgId || !orgRole) { + return { + success: false, + error: 'Agents are only available for organization accounts.', + }; + } + if (orgRole !== 'org:admin') { return { success: false, @@ -105,6 +113,14 @@ export async function revokeAgent( const { orgId, orgRole } = authResult; + // Agents are only available for organizations, not personal accounts + if (!orgId || !orgRole) { + return { + success: false, + error: 'Agents are only available for organization accounts.', + }; + } + if (orgRole !== 'org:admin') { return { success: false, diff --git a/apps/web/src/actions/analytics/events.ts b/apps/web/src/actions/analytics/events.ts index b1dd7bc65d..48334bf542 100644 --- a/apps/web/src/actions/analytics/events.ts +++ b/apps/web/src/actions/analytics/events.ts @@ -92,17 +92,24 @@ export const getUsage = async ({ requestedUserId: userId, }); - if (!orgId) { - return {}; + // For personal accounts, query by userId instead of orgId + if (!orgId && !effectiveUserId) { + return {}; // Personal accounts must have a userId } const userFilter = effectiveUserId ? 'AND userId = {userId: String}' : ''; + // Build query conditions based on account type + const orgCondition = !orgId ? 'orgId IS NULL' : 'orgId = {orgId: String}'; + const queryParams: Record = { - orgId: orgId!, timePeriod, }; + if (orgId) { + queryParams.orgId = orgId; + } + if (effectiveUserId) { queryParams.userId = effectiveUserId; } @@ -117,7 +124,7 @@ export const getUsage = async ({ SUM(COALESCE(cost, 0)) AS cost FROM events WHERE - orgId = {orgId: String} + ${orgCondition} AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY) ${userFilter} GROUP BY 1 @@ -332,8 +339,10 @@ export const getTasks = async ({ effectiveUserId = authResult.effectiveUserId; } - if (!orgId) { - return []; + // For personal accounts, query by userId instead of orgId + // Exception: when skipAuth is true (for public shares), we can query without userId + if (!orgId && !effectiveUserId && !skipAuth) { + return []; // Personal accounts must have a userId unless we're skipping auth } const userFilter = effectiveUserId ? 'AND e.userId = {userId: String}' : ''; @@ -345,8 +354,14 @@ export const getTasks = async ({ const messageTaskFilter = taskId ? 'AND taskId = {taskId: String}' : ''; + // Build query conditions based on account type + const orgCondition = !orgId ? 'e.orgId IS NULL' : 'e.orgId = {orgId: String}'; + + const messageOrgCondition = !orgId + ? 'orgId IS NULL' + : 'orgId = {orgId: String}'; + const queryParams: Record = { - orgId: orgId!, types: [ TelemetryEventName.TASK_CREATED, TelemetryEventName.TASK_COMPLETED, @@ -354,6 +369,10 @@ export const getTasks = async ({ ], }; + if (orgId) { + queryParams.orgId = orgId; + } + if (effectiveUserId) { queryParams.userId = effectiveUserId; } @@ -370,7 +389,7 @@ export const getTasks = async ({ argMin(text, ts) as title, argMin(mode, ts) as mode FROM messages - WHERE orgId = {orgId: String} + WHERE ${messageOrgCondition} ${messageUserFilter} ${messageTaskFilter} GROUP BY taskId @@ -389,7 +408,7 @@ export const getTasks = async ({ FROM events e LEFT JOIN first_messages fm ON e.taskId = fm.taskId WHERE - e.orgId = {orgId: String} + ${orgCondition} AND e.type IN ({types: Array(String)}) AND e.modelId IS NOT NULL ${userFilter} @@ -440,14 +459,17 @@ export const getHourlyUsageByUser = async ({ requestedUserId: userId, }); - if (!orgId) { - return []; + // For personal accounts, query by userId instead of orgId + if (!orgId && !effectiveUserId) { + return []; // Personal accounts must have a userId } const userFilter = effectiveUserId ? 'AND userId = {userId: String}' : ''; + // Build query conditions based on account type + const orgCondition = !orgId ? 'orgId IS NULL' : 'orgId = {orgId: String}'; + const queryParams: Record = { - orgId: orgId!, timePeriod, types: [ TelemetryEventName.TASK_CREATED, @@ -456,6 +478,10 @@ export const getHourlyUsageByUser = async ({ ], }; + if (orgId) { + queryParams.orgId = orgId; + } + if (effectiveUserId) { queryParams.userId = effectiveUserId; } @@ -470,7 +496,7 @@ export const getHourlyUsageByUser = async ({ SUM(CASE WHEN type = '${TelemetryEventName.LLM_COMPLETION}' THEN COALESCE(cost, 0) ELSE 0 END) AS cost FROM events WHERE - orgId = {orgId: String} + ${orgCondition} AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY) AND type IN ({types: Array(String)}) ${userFilter} diff --git a/apps/web/src/actions/analytics/messages.ts b/apps/web/src/actions/analytics/messages.ts index 05698c8ebd..1e1d3023a8 100644 --- a/apps/web/src/actions/analytics/messages.ts +++ b/apps/web/src/actions/analytics/messages.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { analytics } from '@/lib/server'; +import { authorizeAnalytics } from '@/actions/auth'; /** * getMessages @@ -26,17 +27,44 @@ const messageSchema = z.object({ export type Message = z.infer; -export const getMessages = async (taskId: string): Promise => { +export const getMessages = async ( + taskId: string, + orgId?: string | null, + userId?: string | null, + skipAuth = false, +): Promise => { + // Authorize the request - this will handle both personal and org contexts + // Skip auth for public shares viewed by unauthenticated users + if (!skipAuth) { + await authorizeAnalytics({ + requestedOrgId: orgId, + requestedUserId: userId, + allowCrossUserAccess: true, // Allow viewing messages within authorized tasks + }); + } + + // For personal accounts, query with orgId IS NULL + // For organizations, query with specific orgId + const orgCondition = !orgId ? 'orgId IS NULL' : 'orgId = {orgId: String}'; + + const queryParams: Record = { taskId }; + if (orgId) { + queryParams.orgId = orgId; + } + const results = await analytics.query({ query: ` SELECT * FROM messages WHERE taskId = {taskId: String} + AND ${orgCondition} ORDER BY ts ASC `, format: 'JSONEachRow', - query_params: { taskId }, + query_params: queryParams, }); - return z.array(messageSchema).parse(await results.json()); + const messages = z.array(messageSchema).parse(await results.json()); + + return messages; }; diff --git a/apps/web/src/actions/auth.ts b/apps/web/src/actions/auth.ts index b888948c14..865fb47787 100644 --- a/apps/web/src/actions/auth.ts +++ b/apps/web/src/actions/auth.ts @@ -18,8 +18,15 @@ export async function authorize(): Promise { return { success: false, error: 'Unauthorized: User required' }; } + // Personal context is valid - no org required if (!orgId) { - return { success: false, error: 'Unauthorized: Organization required' }; + return { + success: true, + userType: 'user', + userId, + orgId: null, + orgRole: null, + }; } return { @@ -111,6 +118,27 @@ export async function authorizeAnalytics({ }) { const { orgId: authOrgId, orgRole, userId: authUserId } = await auth(); + if (!authUserId) { + throw new Error('Unauthorized: User required'); + } + + // Handle personal context + if (!authOrgId && !requestedOrgId) { + // Personal context - user can only access their own data + if (requestedUserId && requestedUserId !== authUserId) { + throw new Error( + 'Unauthorized: Personal users can only access their own data', + ); + } + + return { + authOrgId: null, + authUserId, + orgRole: null, + effectiveUserId: authUserId, + }; + } + // Ensure user is authenticated and belongs to the organization if (!authOrgId || !authUserId || authOrgId !== requestedOrgId) { throw new Error('Unauthorized: Invalid organization access'); diff --git a/apps/web/src/actions/organizationSettings.ts b/apps/web/src/actions/organizationSettings.ts index 7cae24f948..4fb54ad922 100644 --- a/apps/web/src/actions/organizationSettings.ts +++ b/apps/web/src/actions/organizationSettings.ts @@ -24,6 +24,11 @@ export async function getOrganizationSettings(): Promise { throw new Error('Unauthorized'); } + // Organization settings are only available for organizations, not personal accounts + if (!authResult.orgId) { + return ORGANIZATION_DEFAULT; + } + const settings = await db .select() .from(orgSettings) @@ -64,6 +69,13 @@ export async function updateOrganization(data: UpdateOrganizationRequest) { const { userId, orgId } = authResult; + // Organization settings are only available for organizations, not personal accounts + if (!orgId) { + throw new Error( + 'Organization settings are only available for organization accounts', + ); + } + const validatedData = updateOrganizationSchema.parse(data); // Perform database update in a transaction diff --git a/apps/web/src/actions/taskSharing.ts b/apps/web/src/actions/taskSharing.ts index 8c5fab5863..9657638018 100644 --- a/apps/web/src/actions/taskSharing.ts +++ b/apps/web/src/actions/taskSharing.ts @@ -44,8 +44,8 @@ export async function canShareTask(taskId: string): Promise<{ task?: TaskWithUser; error?: string; userId?: string; - orgId?: string; - orgRole?: string; + orgId?: string | null; + orgRole?: string | null; }> { try { // Get authentication info @@ -57,6 +57,24 @@ export async function canShareTask(taskId: string): Promise<{ const { userId, orgId, orgRole } = authResult; + // Handle personal context + if (!orgId) { + // Personal users can only share tasks they created + const tasks = await getTasks({ taskId, orgId: null, userId }); + const task = tasks[0]; + + if (!task || task.userId !== userId) { + return { + canShare: false, + error: + 'Task not found or you do not have permission to share this task', + }; + } + + return { canShare: true, task, userId, orgId: null, orgRole: null }; + } + + // Organization context - existing logic // Admins can share any task in the organization if (orgRole === 'org:admin') { const tasks = await getTasks({ @@ -115,15 +133,6 @@ export async function createTaskShare(data: CreateTaskShareRequest) { visibility = TaskShareVisibility.ORGANIZATION, } = result.data; - const orgSettingsData = await getOrganizationSettings(); - - if (!orgSettingsData.cloudSettings?.enableTaskSharing) { - return { - success: false, - error: 'Task sharing is not enabled for this organization', - }; - } - // Check if user can share this specific task (includes auth) const { canShare, task, error, userId, orgId, orgRole } = await canShareTask(taskId); @@ -132,17 +141,39 @@ export async function createTaskShare(data: CreateTaskShareRequest) { return { success: false, error: error || 'Access denied' }; } - if (!task || !userId || !orgId || !orgRole) { + if (!task || !userId) { return { success: false, error: 'Task not found or authentication failed', }; } - const expirationDaysToUse = - expirationDays || - orgSettingsData.cloudSettings?.taskShareExpirationDays || - DEFAULT_SHARE_EXPIRATION_DAYS; + let expirationDaysToUse: number; + let shareVisibility: string; + + if (!orgId) { + // Personal account - always public, 30-day expiration + expirationDaysToUse = 30; // Fixed 30-day expiration for personal accounts + shareVisibility = TaskShareVisibility.PUBLIC; // Personal accounts only create public shares + } else { + // Organization account + const orgSettingsData = await getOrganizationSettings(); + + if (!orgSettingsData.cloudSettings?.enableTaskSharing) { + return { + success: false, + error: 'Task sharing is not enabled for this organization', + }; + } + + expirationDaysToUse = + expirationDays || + orgSettingsData.cloudSettings?.taskShareExpirationDays || + DEFAULT_SHARE_EXPIRATION_DAYS; + + // Organization shares default to organization visibility + shareVisibility = visibility; + } const expiresAt = calculateExpirationDate(expirationDaysToUse); const shareToken = generateShareToken(); @@ -152,10 +183,10 @@ export async function createTaskShare(data: CreateTaskShareRequest) { .insert(taskShares) .values({ taskId, - orgId, + orgId, // Will be null for personal accounts createdByUserId: userId, shareToken, - visibility, + visibility: shareVisibility, expiresAt, }) .returning(); @@ -164,25 +195,28 @@ export async function createTaskShare(data: CreateTaskShareRequest) { throw new Error('Failed to create task share'); } - await insertAuditLog(tx, { - userId, - orgId, - targetType: AuditLogTargetType.TASK_SHARE, - targetId: taskId, - newValue: { - action: 'created', - shareId: insertedShare[0].id, - visibility, - expiresAt: expiresAt.toISOString(), - taskOwnerId: task.userId, - sharedByAdmin: orgRole === 'org:admin' && task.userId !== userId, - }, - description: `Created ${visibility} task share for task ${taskId}${ - orgRole === 'org:admin' && task.userId !== userId - ? ` (admin sharing task created by ${task.user.name})` - : '' - }`, - }); + // Only create audit log for organization accounts + if (orgId) { + await insertAuditLog(tx, { + userId, + orgId, + targetType: AuditLogTargetType.TASK_SHARE, + targetId: taskId, + newValue: { + action: 'created', + shareId: insertedShare[0].id, + visibility: shareVisibility, + expiresAt: expiresAt.toISOString(), + taskOwnerId: task.userId, + sharedByAdmin: orgRole === 'org:admin' && task.userId !== userId, + }, + description: `Created ${shareVisibility} task share for task ${taskId}${ + orgRole === 'org:admin' && task.userId !== userId + ? ` (admin sharing task created by ${task.user.name})` + : '' + }`, + }); + } return insertedShare; }); @@ -251,16 +285,17 @@ export async function getTaskByShareToken(token: string): Promise<{ const userId = authResult.success ? authResult.userId : null; const orgId = authResult.success ? authResult.orgId : null; + // For organization shares, require auth and matching orgId if (!userId || !orgId || orgId !== share.orgId) { throw new Error('Authentication required for organization shares'); } } - // For public shares, no auth check needed + // For public shares (including personal shares), no auth check needed // Get task data based on visibility const tasks = await getTasks({ taskId: share.taskId, - orgId: share.orgId, + orgId: share.orgId, // Will be null for personal shares allowCrossUserAccess: true, skipAuth: share.visibility === TaskShareVisibility.PUBLIC, // Skip auth for public shares }); @@ -270,7 +305,12 @@ export async function getTaskByShareToken(token: string): Promise<{ return null; } - const messages = await getMessages(share.taskId); + const messages = await getMessages( + share.taskId, + share.orgId, + task.userId, + share.visibility === TaskShareVisibility.PUBLIC, // Skip auth for public shares + ); return { task, @@ -309,10 +349,25 @@ export async function deleteTaskShare(shareId: string) { } // First, find the share to check permissions + let whereConditions; + if (!orgId) { + // For personal context, find shares with null orgId + whereConditions = and( + eq(taskShares.id, shareId), + sql`${taskShares.orgId} IS NULL`, + ); + } else { + // For organization context, find shares with matching orgId + whereConditions = and( + eq(taskShares.id, shareId), + eq(taskShares.orgId, orgId), + ); + } + const [share] = await db .select() .from(taskShares) - .where(and(eq(taskShares.id, shareId), eq(taskShares.orgId, orgId))) + .where(whereConditions) .limit(1); if (!share) { @@ -320,34 +375,47 @@ export async function deleteTaskShare(shareId: string) { } // Check if user can delete this share - // Admins can delete any share, members can only delete shares they created - if (orgRole !== 'org:admin' && share.createdByUserId !== userId) { - return { - success: false, - error: 'Access denied: You can only delete shares you created', - }; + if (!orgId) { + // Personal users can only delete shares they created + if (share.createdByUserId !== userId) { + return { + success: false, + error: 'Access denied: You can only delete shares you created', + }; + } + } else { + // Organization context - admins can delete any share, members can only delete shares they created + if (orgRole !== 'org:admin' && share.createdByUserId !== userId) { + return { + success: false, + error: 'Access denied: You can only delete shares you created', + }; + } } await db.transaction(async (tx) => { await tx.delete(taskShares).where(eq(taskShares.id, shareId)); - await insertAuditLog(tx, { - userId, - orgId, - targetType: AuditLogTargetType.TASK_SHARE, - targetId: share.taskId, - newValue: { - action: 'deleted', - shareId: share.id, - deletedByAdmin: - orgRole === 'org:admin' && share.createdByUserId !== userId, - }, - description: `Deleted task share for task ${share.taskId}${ - orgRole === 'org:admin' && share.createdByUserId !== userId - ? ' (admin deletion)' - : '' - }`, - }); + // Only create audit log for organization accounts + if (orgId) { + await insertAuditLog(tx, { + userId, + orgId, + targetType: AuditLogTargetType.TASK_SHARE, + targetId: share.taskId, + newValue: { + action: 'deleted', + shareId: share.id, + deletedByAdmin: + orgRole === 'org:admin' && share.createdByUserId !== userId, + }, + description: `Deleted task share for task ${share.taskId}${ + orgRole === 'org:admin' && share.createdByUserId !== userId + ? ' (admin deletion)' + : '' + }`, + }); + } }); return { success: true, message: 'Task share deleted successfully' }; @@ -369,19 +437,35 @@ export async function getTaskShares(taskId: string): Promise { throw new Error(error || 'Task not found or access denied'); } - if (!userId || !orgId) { + if (!userId) { throw new Error('Authentication failed'); } - // For admins, show all shares for the task - // For members, only show shares they created - const whereConditions = [ - eq(taskShares.taskId, taskId), - eq(taskShares.orgId, orgId), - ]; + let whereConditions; - if (orgRole !== 'org:admin') { - whereConditions.push(eq(taskShares.createdByUserId, userId)); + if (!orgId) { + // Personal context - only show shares they created for this task + whereConditions = [ + eq(taskShares.taskId, taskId), + sql`${taskShares.orgId} IS NULL`, + eq(taskShares.createdByUserId, userId), + ]; + } else { + // Organization context - existing logic + if (!orgId) { + throw new Error('Organization ID required for organization context'); + } + + // For admins, show all shares for the task + // For members, only show shares they created + whereConditions = [ + eq(taskShares.taskId, taskId), + eq(taskShares.orgId, orgId), + ]; + + if (orgRole !== 'org:admin') { + whereConditions.push(eq(taskShares.createdByUserId, userId)); + } } const shares = await db diff --git a/apps/web/src/app/(authenticated)/layout.tsx b/apps/web/src/app/(authenticated)/layout.tsx index 3bde0750dc..3aa8c2e6d0 100644 --- a/apps/web/src/app/(authenticated)/layout.tsx +++ b/apps/web/src/app/(authenticated)/layout.tsx @@ -23,7 +23,9 @@ export default async function AuthenticatedLayout({ // Set enhanced Sentry context for authenticated users. const { userId, orgId, orgRole } = authResult; setSentryUserContext({ id: userId, orgId, orgRole }); - setSentryOrganizationContext(orgId, orgRole); + if (orgId && orgRole) { + setSentryOrganizationContext(orgId, orgRole); + } return ( <> diff --git a/apps/web/src/app/(authenticated)/usage/TaskModal.tsx b/apps/web/src/app/(authenticated)/usage/TaskModal.tsx index bbe52e2402..9b2163982a 100644 --- a/apps/web/src/app/(authenticated)/usage/TaskModal.tsx +++ b/apps/web/src/app/(authenticated)/usage/TaskModal.tsx @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query'; +import { useAuth } from '@clerk/nextjs'; import type { TaskWithUser } from '@/actions/analytics'; import { getMessages } from '@/actions/analytics'; @@ -16,9 +17,11 @@ type TaskModalProps = { }; export const TaskModal = ({ task, open, onClose }: TaskModalProps) => { + const { orgId, userId } = useAuth(); + const { data: messages = [] } = useQuery({ - queryKey: ['messages', task.taskId], - queryFn: () => getMessages(task.taskId), + queryKey: ['messages', task.taskId, orgId, userId], + queryFn: () => getMessages(task.taskId, orgId, userId, false), enabled: open && !!task.taskId, }); diff --git a/apps/web/src/app/(authenticated)/usage/Tasks.tsx b/apps/web/src/app/(authenticated)/usage/Tasks.tsx index e7f6be13a1..38818cbd19 100644 --- a/apps/web/src/app/(authenticated)/usage/Tasks.tsx +++ b/apps/web/src/app/(authenticated)/usage/Tasks.tsx @@ -25,13 +25,18 @@ export const Tasks = ({ const { orgId } = useAuth(); const { data = [], isPending } = useQuery({ - queryKey: ['getTasks', orgId, userRole === 'member' ? currentUserId : null], + queryKey: [ + 'getTasks', + orgId, + userRole === 'member' ? currentUserId : null, + !orgId, + ], queryFn: () => getTasks({ orgId, userId: userRole === 'member' ? currentUserId : undefined, }), - enabled: !!orgId, + enabled: true, // Run for both personal and organization context }); const tasks = useMemo(() => { diff --git a/apps/web/src/app/(centered)/extension/sign-in/page.tsx b/apps/web/src/app/(centered)/extension/sign-in/page.tsx index 259a239035..7e210875f3 100644 --- a/apps/web/src/app/(centered)/extension/sign-in/page.tsx +++ b/apps/web/src/app/(centered)/extension/sign-in/page.tsx @@ -35,7 +35,9 @@ export default async function Page(props: Props) { redirect(`/sign-in?${authParams.toString()}`); } - if (!orgId) { + // For personal accounts, orgId will be null - that's okay for extensions + // Only redirect to select-org if user is not authenticated or not in personal context + if (!orgId && !(authResult.success && !authResult.orgId)) { redirect(`/select-org?${authParams.toString()}`); } @@ -46,7 +48,7 @@ export default async function Page(props: Props) { const params = new URLSearchParams({ state, code, - organizationId: orgId, + ...(orgId && { organizationId: orgId }), // Only include orgId if it exists }); editorRedirect = new URL( diff --git a/apps/web/src/app/(centered)/select-org/SelectOrg.tsx b/apps/web/src/app/(centered)/select-org/SelectOrg.tsx index f13ecb6bee..c0e61bb000 100644 --- a/apps/web/src/app/(centered)/select-org/SelectOrg.tsx +++ b/apps/web/src/app/(centered)/select-org/SelectOrg.tsx @@ -57,7 +57,8 @@ export const SelectOrg = () => { ); }; diff --git a/apps/web/src/app/api/events/__tests__/route.test.ts b/apps/web/src/app/api/events/__tests__/route.test.ts index ee17f14a8b..7fa6cf36fa 100644 --- a/apps/web/src/app/api/events/__tests__/route.test.ts +++ b/apps/web/src/app/api/events/__tests__/route.test.ts @@ -37,24 +37,44 @@ describe('/api/events POST', () => { }); }); - it('should return 401 when organization is not provided', async () => { + it('should allow personal accounts (orgId is null)', async () => { mockAuth.mockResolvedValue({ userId: 'test-user-id', orgId: null, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + const validEvent = { + type: 'Task Created', + properties: { + appName: 'test-app', + appVersion: '1.0.0', + vscodeVersion: '1.80.0', + platform: 'darwin', + editorName: 'vscode', + language: 'typescript', + mode: 'code', + taskId: 'task-123', + apiProvider: 'anthropic', + modelId: 'claude-3-sonnet', + }, + }; + const request = new NextRequest('http://localhost/api/events', { method: 'POST', - body: JSON.stringify({ type: 'test-event' }), + body: JSON.stringify(validEvent), }); const response = await POST(request); const data = await response.json(); - expect(response.status).toBe(401); - expect(data).toEqual({ - success: false, - error: 'Authentication required', + expect(response.status).toBe(200); + expect(data).toEqual({ success: true, id: expect.any(String) }); + expect(mockCaptureEvent).toHaveBeenCalledWith({ + id: expect.any(String), + orgId: null, // Personal account has null orgId + userId: 'test-user-id', + timestamp: expect.any(Number), + event: validEvent, }); }); }); diff --git a/apps/web/src/components/layout/NavbarHeader.tsx b/apps/web/src/components/layout/NavbarHeader.tsx index b6437dca2e..4d5b4f6e78 100644 --- a/apps/web/src/components/layout/NavbarHeader.tsx +++ b/apps/web/src/components/layout/NavbarHeader.tsx @@ -18,7 +18,8 @@ export const NavbarHeader = (props: NavbarHeaderProps) => ( organizationProfileMode="navigation" organizationProfileUrl="/org" afterCreateOrganizationUrl="/usage" - hidePersonal + hidePersonal={false} + afterSelectPersonalUrl="/usage" />
    diff --git a/apps/web/src/components/task-sharing/ShareButton.tsx b/apps/web/src/components/task-sharing/ShareButton.tsx index 39ce03427c..896c8fc1aa 100644 --- a/apps/web/src/components/task-sharing/ShareButton.tsx +++ b/apps/web/src/components/task-sharing/ShareButton.tsx @@ -11,6 +11,7 @@ import { Check, } from 'lucide-react'; import { toast } from 'sonner'; +import { useAuth } from '@clerk/nextjs'; import type { TaskShare } from '@roo-code-cloud/db'; @@ -52,11 +53,13 @@ export const ShareButton = ({ task }: ShareButtonProps) => { TaskShareVisibility.ORGANIZATION, ); + const { orgId } = useAuth(); const { data: orgSettings } = useOrganizationSettings(); - const expirationDays = - orgSettings?.cloudSettings?.taskShareExpirationDays ?? - DEFAULT_SHARE_EXPIRATION_DAYS; + const expirationDays = !orgId + ? 30 // Fixed 30 days for personal accounts + : (orgSettings?.cloudSettings?.taskShareExpirationDays ?? + DEFAULT_SHARE_EXPIRATION_DAYS); const loadShares = async () => { try { @@ -73,7 +76,10 @@ export const ShareButton = ({ task }: ShareButtonProps) => { if (open) { loadShares(); setNewShareUrl(null); - setVisibility(TaskShareVisibility.ORGANIZATION); // Reset to default when opening + // Set appropriate default visibility based on context + setVisibility( + !orgId ? TaskShareVisibility.PUBLIC : TaskShareVisibility.ORGANIZATION, + ); } }; @@ -144,9 +150,9 @@ export const ShareButton = ({ task }: ShareButtonProps) => { {newShareUrl ? 'Your share link is ready to use.' - : visibility === TaskShareVisibility.ORGANIZATION - ? 'Create a link to share this task with your team.' - : 'Create a public link that anyone can access.'} + : visibility === TaskShareVisibility.PUBLIC + ? 'Create a public link that anyone can access.' + : 'Create a link to share this task with your team.'} @@ -196,81 +202,83 @@ export const ShareButton = ({ task }: ShareButtonProps) => { ) : ( <> - {/* Visibility Selector */} -
    - -
    -
    -
    -
    - - Organization - - {visibility === TaskShareVisibility.ORGANIZATION && ( - - )} +
    +
    -

    - Only members of your organization can view -

    -
    - +
    +
    + + Organization + + {visibility === TaskShareVisibility.ORGANIZATION && ( + + )} +
    +

    + Only members of your organization can view +

    +
    + - +
    +
    + + Public + + {visibility === TaskShareVisibility.PUBLIC && ( + + )} +
    +

    + Anyone with the link can view +

    +
    + +
    - + )}