From b6af87b9fd12ef37f303d43739ef0583ef98f3d4 Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Tue, 3 Jun 2025 12:39:22 -0700 Subject: [PATCH] Bring back task sharing (#74) --- src/actions/analytics/events.ts | 2 +- src/actions/defaultParameters.ts | 98 ------ src/actions/providerWhitelist.ts | 146 --------- src/actions/taskSharing.ts | 292 ++++++++++++++++++ .../(authenticated)/settings/SettingsForm.tsx | 191 ++++++++++++ .../(authenticated)/settings/SettingsPage.tsx | 25 ++ src/app/(authenticated)/settings/page.tsx | 10 + .../(authenticated)/share/[token]/page.tsx | 89 ++++++ .../telemetry/TelemetryForm.tsx | 115 ------- .../telemetry/TelemetrySettings.tsx | 31 -- src/app/(authenticated)/telemetry/page.tsx | 5 - src/app/(authenticated)/usage/TaskDrawer.tsx | 16 +- src/app/api/ping/route.ts | 34 -- src/components/layout/NavbarMenu.tsx | 4 +- src/components/task-sharing/ShareButton.tsx | 227 ++++++++++++++ .../task-sharing/SharedTaskView.tsx | 81 +++++ src/components/task-sharing/index.ts | 2 + src/components/ui/index.ts | 1 + src/components/usage/TaskCard.tsx | 2 +- src/components/usage/UsageChart.tsx | 2 +- src/db/types.ts | 10 +- src/lib/__tests__/task-sharing.test.ts | 98 ++++++ ...neUtils.test.ts => timezone-utils.test.ts} | 4 +- src/lib/clipboard.ts | 32 ++ src/lib/server/index.ts | 1 + src/lib/server/task-sharing.ts | 5 + src/lib/task-sharing.ts | 24 ++ src/lib/{taskUtils.ts => task-utils.ts} | 0 .../{timezoneUtils.ts => timezone-utils.ts} | 0 src/types/index.ts | 1 + src/types/task-sharing.ts | 12 + 31 files changed, 1119 insertions(+), 441 deletions(-) delete mode 100644 src/actions/defaultParameters.ts delete mode 100644 src/actions/providerWhitelist.ts create mode 100644 src/actions/taskSharing.ts create mode 100644 src/app/(authenticated)/settings/SettingsForm.tsx create mode 100644 src/app/(authenticated)/settings/SettingsPage.tsx create mode 100644 src/app/(authenticated)/settings/page.tsx create mode 100644 src/app/(authenticated)/share/[token]/page.tsx delete mode 100644 src/app/(authenticated)/telemetry/TelemetryForm.tsx delete mode 100644 src/app/(authenticated)/telemetry/TelemetrySettings.tsx delete mode 100644 src/app/(authenticated)/telemetry/page.tsx delete mode 100644 src/app/api/ping/route.ts create mode 100644 src/components/task-sharing/ShareButton.tsx create mode 100644 src/components/task-sharing/SharedTaskView.tsx create mode 100644 src/components/task-sharing/index.ts create mode 100644 src/lib/__tests__/task-sharing.test.ts rename src/lib/__tests__/{timezoneUtils.test.ts => timezone-utils.test.ts} (96%) create mode 100644 src/lib/clipboard.ts create mode 100644 src/lib/server/task-sharing.ts create mode 100644 src/lib/task-sharing.ts rename src/lib/{taskUtils.ts => task-utils.ts} (100%) rename src/lib/{timezoneUtils.ts => timezone-utils.ts} (100%) create mode 100644 src/types/task-sharing.ts diff --git a/src/actions/analytics/events.ts b/src/actions/analytics/events.ts index 3c1c135fec..05e556e66f 100644 --- a/src/actions/analytics/events.ts +++ b/src/actions/analytics/events.ts @@ -4,8 +4,8 @@ import { z } from 'zod'; import { auth } from '@clerk/nextjs/server'; import { - TelemetryEventName, type RooCodeTelemetryEvent, + TelemetryEventName, } from '@roo-code/types'; import type { AnyTimePeriod } from '@/types'; diff --git a/src/actions/defaultParameters.ts b/src/actions/defaultParameters.ts deleted file mode 100644 index 1d5533401c..0000000000 --- a/src/actions/defaultParameters.ts +++ /dev/null @@ -1,98 +0,0 @@ -'use server'; - -import { sql } from 'drizzle-orm'; -import { z } from 'zod'; - -import { ORGANIZATION_ALLOW_ALL } from '@roo-code/types'; - -import type { ApiResponse } from '@/types'; -import { AuditLogTargetType, client as db, orgSettings } from '@/db/server'; -import { isAuthSuccess, handleError } from '@/lib/server'; - -import { validateAuth } from './auth'; -import { insertAuditLog } from './auditLogs'; - -const defaultParametersSchema = z.object({ - experimentalPowerSteering: z.boolean().optional(), - terminalOutputLineLimit: z.number().int().nonnegative().optional(), - terminalCompressProgressBar: z.boolean().optional(), - inheritEnvVars: z.boolean().optional(), - terminalShellIntegrationDisabled: z.boolean().optional(), - terminalShellIntegrationTimeout: z.number().int().nonnegative().optional(), - terminalCommandDelay: z.number().int().nonnegative().optional(), - terminalZshClearEolMark: z.boolean().optional(), - enablePowerlevel10k: z.boolean().optional(), - maxOpenTabsContext: z.number().int().nonnegative().optional(), - maxWorkspaceFiles: z.number().int().nonnegative().optional(), - showRooIgnoredFiles: z.boolean().optional(), - maxReadFileLine: z.number().int().gte(-1).optional(), - enableCheckpoints: z.boolean().optional(), - useCustomTemperature: z.boolean().optional(), - temperature: z.number().nonnegative().optional(), - rateLimit: z.number().nonnegative().optional(), - enableEditingThroughDiffs: z.boolean().optional(), - matchPrecision: z.number().int().min(50).max(100).optional(), -}); - -type DefaultParametersRequest = z.infer; - -/** - * Updates default parameters and creates an audit log entry - * @param data The default parameters data to update - * @returns API response indicating success or failure - */ -export async function updateDefaultParameters( - data: DefaultParametersRequest, -): Promise { - try { - const authResult = await validateAuth(); - - if (!isAuthSuccess(authResult)) { - return authResult; - } - - const result = defaultParametersSchema.safeParse(data); - - if (!result.success) { - return { success: false, error: 'Invalid request data' }; - } - - const validatedData = result.data; - - await db.transaction(async (tx) => { - await tx - .insert(orgSettings) - .values({ - orgId: authResult.orgId, - version: 1, - defaultSettings: validatedData, - allowList: ORGANIZATION_ALLOW_ALL, - }) - .onConflictDoUpdate({ - target: orgSettings.orgId, - set: { - defaultSettings: validatedData, - version: sql`${orgSettings.version} + 1`, - }, - }); - - // TODO: consider trying to capture the changes more granularly, - // although that would prevent upsert - await insertAuditLog(tx, { - userId: authResult.userId, - orgId: authResult.orgId, - targetType: AuditLogTargetType.DEFAULT_PARAMETERS, - targetId: 'default-parameters', - newValue: validatedData, - description: 'Updated default parameters', - }); - }); - - return { - success: true, - message: 'Default parameters updated successfully', - }; - } catch (error) { - return handleError(error, 'default_parameters'); - } -} diff --git a/src/actions/providerWhitelist.ts b/src/actions/providerWhitelist.ts deleted file mode 100644 index c4ac0fa375..0000000000 --- a/src/actions/providerWhitelist.ts +++ /dev/null @@ -1,146 +0,0 @@ -'use server'; - -import { z } from 'zod'; - -import type { ApiResponse } from '@/types'; -import { handleError, isAuthSuccess } from '@/lib/server'; -import { AuditLogTargetType } from '@/db/server'; - -import { validateAuth } from './auth'; -import { createAuditLog } from './auditLogs'; - -const allowAllProvidersSchema = z.object({ - allowAllProviders: z.boolean(), - policyVersion: z.number().int().positive(), -}); - -const providerToggleSchema = z.object({ - providerId: z.string().min(1, 'Provider ID is required'), - enabled: z.boolean(), - policyVersion: z.number().int().positive(), -}); - -const modelToggleSchema = z.object({ - providerId: z.string().min(1, 'Provider ID is required'), - modelId: z.string().min(1, 'Model ID is required'), - enabled: z.boolean(), - policyVersion: z.number().int().positive(), -}); - -type AllowAllProvidersRequest = z.infer; -type ProviderToggleRequest = z.infer; -type ModelToggleRequest = z.infer; - -// Note: I haven't thought much about this API; this is a temporary stub to get -// audit logs working. -// Feel free to refactor when implementing actual data writes. -export async function updateAllowAllProviders( - data: AllowAllProvidersRequest, -): Promise { - try { - const authResult = await validateAuth(); - - if (!isAuthSuccess(authResult)) { - return authResult; - } - - const result = allowAllProvidersSchema.safeParse(data); - - if (!result.success) { - return { success: false, error: 'Invalid request data' }; - } - - const validatedData = result.data; - - await createAuditLog({ - userId: authResult.userId, - orgId: authResult.orgId, - targetType: AuditLogTargetType.PROVIDER_WHITELIST, - targetId: 'allow-all-providers', - newValue: { allowAllProviders: validatedData.allowAllProviders }, - description: `${validatedData.allowAllProviders ? 'Enabled' : 'Disabled'} all providers`, - }); - - return { - success: true, - message: 'Allow all providers setting updated successfully', - }; - } catch (error) { - return handleError(error, 'provider_whitelist_allow_all_providers'); - } -} - -// Note: I haven't thought much about this API; this is a temporary stub to get -// audit logs working. -// Feel free to refactor when implementing actual data writes. -export async function updateProviderStatus( - data: ProviderToggleRequest, -): Promise { - try { - const authResult = await validateAuth(); - - if (!isAuthSuccess(authResult)) { - return authResult; - } - - const result = providerToggleSchema.safeParse(data); - - if (!result.success) { - return { success: false, error: 'Invalid request data' }; - } - - const validatedData = result.data; - - await createAuditLog({ - userId: authResult.userId, - orgId: authResult.orgId, - targetType: AuditLogTargetType.PROVIDER_WHITELIST, - targetId: validatedData.providerId, - newValue: { enabled: validatedData.enabled }, - description: `${validatedData.enabled ? 'Enabled' : 'Disabled'} provider ${validatedData.providerId}`, - }); - - return { - success: true, - message: 'Provider status updated successfully', - }; - } catch (error) { - return handleError(error, 'provider_toggle'); - } -} - -// Note: I haven't thought much about this API; this is a temporary stub to get -// audit logs working. -// Feel free to refactor when implementing actual data writes. -export async function updateModelStatus( - data: ModelToggleRequest, -): Promise { - try { - const authResult = await validateAuth(); - - if (!isAuthSuccess(authResult)) { - return authResult; - } - - const result = modelToggleSchema.safeParse(data); - - if (!result.success) { - return { success: false, error: 'Invalid request data' }; - } - - const validatedData = result.data; - - await createAuditLog({ - userId: authResult.userId, - orgId: authResult.orgId, - targetType: AuditLogTargetType.PROVIDER_WHITELIST, - targetId: `${validatedData.providerId}:${validatedData.modelId}`, - newValue: { enabled: validatedData.enabled }, - description: `${validatedData.enabled ? 'Enabled' : 'Disabled'} model ${validatedData.modelId} for provider ${validatedData.providerId}`, - }); - - return { success: true, message: 'Model status updated successfully' }; - } catch (error) { - return handleError(error, 'model_toggle'); - } -} diff --git a/src/actions/taskSharing.ts b/src/actions/taskSharing.ts new file mode 100644 index 0000000000..208577ed6c --- /dev/null +++ b/src/actions/taskSharing.ts @@ -0,0 +1,292 @@ +'use server'; + +import { eq, and, sql, desc } from 'drizzle-orm'; +import { auth } from '@clerk/nextjs/server'; + +import { + type ApiResponse, + type CreateTaskShareRequest, + createTaskShareSchema, + shareIdSchema, +} from '@/types'; +import type { Message } from '@/types/analytics'; +import { type TaskShare, AuditLogTargetType } from '@/db'; +import { client as db, taskShares } from '@/db/server'; +import { handleError, isAuthSuccess, generateShareToken } from '@/lib/server'; +import { + isValidShareToken, + isShareExpired, + calculateExpirationDate, + createShareUrl, + DEFAULT_SHARE_EXPIRATION_DAYS, +} from '@/lib/task-sharing'; +import { type TaskWithUser, getTasks, getMessages } from '@/actions/analytics'; + +import { validateAuth } from './auth'; +import { insertAuditLog } from './auditLogs'; +import { getOrganizationSettings } from './organizationSettings'; + +type TaskShareResponse = ApiResponse & { + data?: { + shareUrl: string; + shareId: string; + expiresAt: Date | null; + }; +}; + +export async function createTaskShare( + data: CreateTaskShareRequest, +): Promise { + try { + const authResult = await validateAuth(); + + if (!isAuthSuccess(authResult)) { + return authResult; + } + + const { userId, orgId } = authResult; + + const result = createTaskShareSchema.safeParse(data); + + if (!result.success) { + return { success: false, error: 'Invalid request data' }; + } + + const { taskId, expirationDays } = result.data; + + const orgSettingsData = await getOrganizationSettings(); + + if (!orgSettingsData.cloudSettings?.enableTaskSharing) { + return { + success: false, + error: 'Task sharing is not enabled for this organization', + }; + } + + const tasks = await getTasks({ orgId, userId }); + const task = tasks.find((t) => t.taskId === taskId); + + if (!task) { + return { success: false, error: 'Task not found or access denied' }; + } + + const expirationDaysToUse = + expirationDays || + orgSettingsData.cloudSettings?.taskShareExpirationDays || + DEFAULT_SHARE_EXPIRATION_DAYS; + + const expiresAt = calculateExpirationDate(expirationDaysToUse); + const shareToken = generateShareToken(); + + const newShares = await db.transaction(async (tx) => { + const insertedShare = await tx + .insert(taskShares) + .values({ + taskId, + orgId, + createdByUserId: userId, + shareToken, + expiresAt, + }) + .returning(); + + if (!insertedShare[0]) { + 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, + expiresAt: expiresAt.toISOString(), + }, + description: `Created task share for task ${taskId}`, + }); + + return insertedShare; + }); + + const newShare = newShares[0]; + + if (!newShare) { + return { success: false, error: 'Failed to create task share' }; + } + + const shareUrl = createShareUrl(shareToken); + + return { + success: true, + data: { shareUrl, shareId: newShare.id, expiresAt }, + message: 'Task share created successfully', + }; + } catch (error) { + return handleError(error, 'task_sharing'); + } +} + +/** + * Get task data by share token (for viewing shared tasks) + */ +export async function getTaskByShareToken( + token: string, +): Promise<{ task: TaskWithUser; messages: Message[] } | null> { + try { + const { userId, orgId } = await auth(); + + if (!userId || !orgId) { + throw new Error('Authentication required'); + } + + if (!isValidShareToken(token)) { + return null; + } + + const [share] = await db + .select() + .from(taskShares) + .where(and(eq(taskShares.shareToken, token), eq(taskShares.orgId, orgId))) + .limit(1); + + if (!share) { + return null; + } + + if (isShareExpired(share.expiresAt)) { + return null; + } + + const tasks = await getTasks({ orgId: share.orgId }); + const task = tasks.find((t) => t.taskId === share.taskId); + + if (!task) { + return null; + } + + const messages = await getMessages(share.taskId); + + return { task, messages }; + } catch (error) { + console.error( + 'Error getting task by share token:', + error instanceof Error ? error.message : 'Unknown error', + ); + + return null; + } +} + +/** + * Delete/revoke a task share. + */ +export async function deleteTaskShare(shareId: string): Promise { + try { + const authResult = await validateAuth(); + + if (!isAuthSuccess(authResult)) { + return authResult; + } + + const { userId, orgId } = authResult; + + const shareIdResult = shareIdSchema.safeParse(shareId); + + if (!shareIdResult.success) { + return { success: false, error: 'Invalid share ID format' }; + } + + const [share] = await db + .select() + .from(taskShares) + .where( + and( + eq(taskShares.id, shareId), + eq(taskShares.orgId, orgId), + eq(taskShares.createdByUserId, userId), + ), + ) + .limit(1); + + if (!share) { + return { success: false, error: 'Share not found or access denied' }; + } + + 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 }, + description: `Deleted task share for task ${share.taskId}`, + }); + }); + + return { success: true, message: 'Task share deleted successfully' }; + } catch (error) { + return handleError(error, 'task_sharing'); + } +} + +/** + * Get all shares for a specific task. + */ +export async function getTaskShares(taskId: string): Promise { + try { + const { userId, orgId } = await auth(); + + if (!userId || !orgId) { + throw new Error('Authentication required'); + } + + const tasks = await getTasks({ orgId, userId }); + const task = tasks.find((t) => t.taskId === taskId); + + if (!task) { + throw new Error('Task not found or access denied'); + } + + const shares = await db + .select() + .from(taskShares) + .where( + and( + eq(taskShares.taskId, taskId), + eq(taskShares.orgId, orgId), + eq(taskShares.createdByUserId, userId), + ), + ) + .orderBy(desc(taskShares.createdAt)); + + return shares.filter((share) => !isShareExpired(share.expiresAt)); + } catch (error) { + console.error('Error getting task shares:', error); + return []; + } +} + +/** + * Clean up expired shares (background job function). + */ +export async function cleanupExpiredShares(): Promise<{ + deletedCount: number; +}> { + try { + const result = await db + .delete(taskShares) + .where( + sql`${taskShares.expiresAt} IS NOT NULL AND ${taskShares.expiresAt} < NOW()`, + ) + .returning({ id: taskShares.id }); + + return { deletedCount: result.length }; + } catch (error) { + console.error('Error cleaning up expired shares:', error); + return { deletedCount: 0 }; + } +} diff --git a/src/app/(authenticated)/settings/SettingsForm.tsx b/src/app/(authenticated)/settings/SettingsForm.tsx new file mode 100644 index 0000000000..feabdfe1cd --- /dev/null +++ b/src/app/(authenticated)/settings/SettingsForm.tsx @@ -0,0 +1,191 @@ +'use client'; + +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Cloud, Share } from 'lucide-react'; + +import type { + OrganizationSettings, + OrganizationCloudSettings, +} from '@roo-code/types'; + +import { QueryKey } from '@/types'; +import { updateOrganization } from '@/actions/organizationSettings'; +import { DEFAULT_SHARE_EXPIRATION_DAYS } from '@/lib/task-sharing'; +import { + Button, + Checkbox, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + Input, +} from '@/components/ui'; +import { Loading } from '@/components/layout'; + +type FormData = { + recordTaskMessages: boolean; + enableTaskSharing: boolean; + taskShareExpirationDays: number; +}; + +type SettingsFormProps = { + orgSettings: OrganizationSettings; +}; + +export const SettingsForm = ({ orgSettings }: SettingsFormProps) => { + const queryClient = useQueryClient(); + const [isSaving, setIsSaving] = useState(false); + + const form = useForm({ + defaultValues: { + recordTaskMessages: + orgSettings.cloudSettings?.recordTaskMessages ?? false, + enableTaskSharing: orgSettings.cloudSettings?.enableTaskSharing ?? false, + taskShareExpirationDays: + orgSettings.cloudSettings?.taskShareExpirationDays ?? + DEFAULT_SHARE_EXPIRATION_DAYS, + }, + }); + + const onSubmit = async (data: FormData) => { + setIsSaving(true); + + try { + const cloudSettings: OrganizationCloudSettings = { + recordTaskMessages: data.recordTaskMessages, + enableTaskSharing: data.enableTaskSharing, + taskShareExpirationDays: data.taskShareExpirationDays, + }; + + const result = await updateOrganization({ cloudSettings }); + + if (result.success) { + queryClient.invalidateQueries({ + queryKey: [QueryKey.GetOrganizationSettings], + }); + toast.success('Settings saved successfully'); + } else { + throw new Error(result.error || 'An unexpected error occurred.'); + } + } catch (error) { + console.error('Failed to update settings:', error); + toast.error('Failed to save settings. Please try again.'); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ + {/* Task Recording Section */} +
+
+ +

Task Recording

+
+
+ ( + + + + +
+ Record task messages + + When enabled, task messages and interactions will be + recorded. + +
+
+ )} + /> +
+
+ + {/* Task Sharing Section */} +
+
+ +

Task Sharing

+
+
+ ( + + + + +
+ Enable task sharing + + Allow users to create shareable links for tasks that can + be viewed by other organization members. + +
+
+ )} + /> + + {form.watch('enableTaskSharing') && ( + ( + + Share Link Expiration (Days) + + { + const value = parseInt(e.target.value); + const validValue = isNaN(value) + ? DEFAULT_SHARE_EXPIRATION_DAYS + : Math.max(1, Math.min(365, value)); + field.onChange(validValue); + }} + disabled={isSaving} + className="w-32" + /> + + + Number of days before shared links expire (1-365 days). + Default is {DEFAULT_SHARE_EXPIRATION_DAYS} days. + + + )} + /> + )} +
+
+ +
+ +
+
+ + ); +}; diff --git a/src/app/(authenticated)/settings/SettingsPage.tsx b/src/app/(authenticated)/settings/SettingsPage.tsx new file mode 100644 index 0000000000..e1e99683e1 --- /dev/null +++ b/src/app/(authenticated)/settings/SettingsPage.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { useOrganizationSettings } from '@/hooks/useOrganizationSettings'; +import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui'; +import { Loading } from '@/components/layout'; + +import { SettingsForm } from './SettingsForm'; + +export const SettingsPage = () => { + const { data: orgSettings } = useOrganizationSettings(); + + return ( + <> + + + Organization Settings + + Configure your organization's settings and preferences. + + + + {orgSettings ? : } + + ); +}; diff --git a/src/app/(authenticated)/settings/page.tsx b/src/app/(authenticated)/settings/page.tsx new file mode 100644 index 0000000000..976a68be6e --- /dev/null +++ b/src/app/(authenticated)/settings/page.tsx @@ -0,0 +1,10 @@ +import { SettingsPage } from './SettingsPage'; + +export default function Settings() { + return ; +} + +export const metadata = { + title: 'Settings', + description: 'Configure your organization settings', +}; diff --git a/src/app/(authenticated)/share/[token]/page.tsx b/src/app/(authenticated)/share/[token]/page.tsx new file mode 100644 index 0000000000..d25dbc098b --- /dev/null +++ b/src/app/(authenticated)/share/[token]/page.tsx @@ -0,0 +1,89 @@ +import { notFound, redirect } from 'next/navigation'; +import { auth } from '@clerk/nextjs/server'; + +import { getTaskByShareToken } from '@/actions/taskSharing'; +import { SharedTaskView } from '@/components/task-sharing/SharedTaskView'; + +type SharedTaskPageProps = { + params: { + token: string; + }; +}; + +export default async function SharedTaskPage({ params }: SharedTaskPageProps) { + const { orgId } = await auth(); + + // Redirect to organization selection if no organization + if (!orgId) { + redirect('/select-org'); + } + + try { + const result = await getTaskByShareToken(params.token); + + if (!result) { + notFound(); + } + + const { task, messages } = result; + + return ( +
+
+
+ Shared Task +
+
+ +
+ ); + } catch (error) { + console.error('Error loading shared task:', error); + + // Check if it's an access denied error + if (error instanceof Error && error.message.includes('Access denied')) { + return ( +
+
+

+ Access Denied +

+

+ You must be a member of the organization to view this shared task. +

+

+ Please contact the person who shared this link to ensure you have + the correct organization access. +

+
+
+ ); + } + + notFound(); + } +} + +export async function generateMetadata({ params }: SharedTaskPageProps) { + try { + const result = await getTaskByShareToken(params.token); + + if (!result) { + return { + title: 'Shared Task Not Found', + }; + } + + const { task } = result; + const title = task.title || `Task by ${task.user.name}`; + + return { + title: `Shared Task: ${title}`, + description: `View shared task details and conversation history`, + }; + } catch (_error) { + return { + title: 'Shared Task', + }; + } +} diff --git a/src/app/(authenticated)/telemetry/TelemetryForm.tsx b/src/app/(authenticated)/telemetry/TelemetryForm.tsx deleted file mode 100644 index a37f815ff7..0000000000 --- a/src/app/(authenticated)/telemetry/TelemetryForm.tsx +++ /dev/null @@ -1,115 +0,0 @@ -'use client'; - -import { useTranslations } from 'next-intl'; -import { useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { useQueryClient } from '@tanstack/react-query'; -import { toast } from 'sonner'; -import { Cloud } from 'lucide-react'; - -import type { - OrganizationSettings, - OrganizationCloudSettings, -} from '@roo-code/types'; - -import { updateOrganization } from '@/actions/organizationSettings'; -import { - Button, - Checkbox, - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, -} from '@/components/ui'; -import { Loading } from '@/components/layout'; - -type FormData = { - recordTaskMessages: boolean; -}; - -type TelemetryFormProps = { - orgSettings: OrganizationSettings; -}; - -export const TelemetryForm = ({ orgSettings }: TelemetryFormProps) => { - const queryClient = useQueryClient(); - const t = useTranslations('TelemetrySettings'); - const [isSaving, setIsSaving] = useState(false); - - const form = useForm({ - defaultValues: { - recordTaskMessages: - orgSettings.cloudSettings?.recordTaskMessages ?? false, - }, - }); - - const onSubmit = async (data: FormData) => { - setIsSaving(true); - - try { - const cloudSettings: OrganizationCloudSettings = { - recordTaskMessages: data.recordTaskMessages, - }; - - const result = await updateOrganization({ cloudSettings }); - - if (result.success) { - queryClient.invalidateQueries({ - queryKey: ['getOrganizationSettings'], - }); - toast.success('Cloud settings saved successfully'); - } else { - throw new Error(result.error || 'An unexpected error occurred.'); - } - } catch (error) { - console.error('Failed to update cloud settings:', error); - toast.error('Failed to save cloud settings. Please try again.'); - } finally { - setIsSaving(false); - } - }; - - return ( -
- -
-
- -

Task Recording

-
-
- ( - - - - -
- {t('record_task_messages')} - - {t('record_task_messages_description')} - -
-
- )} - /> -
-
- -
- -
-
- - ); -}; diff --git a/src/app/(authenticated)/telemetry/TelemetrySettings.tsx b/src/app/(authenticated)/telemetry/TelemetrySettings.tsx deleted file mode 100644 index d69fbe14d4..0000000000 --- a/src/app/(authenticated)/telemetry/TelemetrySettings.tsx +++ /dev/null @@ -1,31 +0,0 @@ -'use client'; - -import { useTranslations } from 'next-intl'; -import { useQuery } from '@tanstack/react-query'; - -import { getOrganizationSettings } from '@/actions/organizationSettings'; -import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui'; -import { Loading } from '@/components/layout'; - -import { TelemetryForm } from './TelemetryForm'; - -export const TelemetrySettings = () => { - const t = useTranslations('TelemetrySettings'); - - const { data: orgSettings } = useQuery({ - queryKey: ['getOrganizationSettings'], - queryFn: getOrganizationSettings, - }); - - return ( - <> - - - {t('title')} - {t('description')} - - - {orgSettings ? : } - - ); -}; diff --git a/src/app/(authenticated)/telemetry/page.tsx b/src/app/(authenticated)/telemetry/page.tsx deleted file mode 100644 index 242deeda83..0000000000 --- a/src/app/(authenticated)/telemetry/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { TelemetrySettings } from './TelemetrySettings'; - -export default function Page() { - return ; -} diff --git a/src/app/(authenticated)/usage/TaskDrawer.tsx b/src/app/(authenticated)/usage/TaskDrawer.tsx index 6514228d33..fff97875b7 100644 --- a/src/app/(authenticated)/usage/TaskDrawer.tsx +++ b/src/app/(authenticated)/usage/TaskDrawer.tsx @@ -3,8 +3,9 @@ import { X } from 'lucide-react'; import type { TaskWithUser } from '@/actions/analytics'; import { getMessages } from '@/actions/analytics'; +import { useOrganizationSettings } from '@/hooks/useOrganizationSettings'; import { formatCurrency, formatNumber } from '@/lib/formatters'; -import { generateFallbackTitle } from '@/lib/taskUtils'; +import { generateFallbackTitle } from '@/lib/task-utils'; import { Drawer, DrawerContent, @@ -13,6 +14,7 @@ import { DrawerTitle, Button, } from '@/components/ui'; +import { ShareButton } from '@/components/task-sharing/ShareButton'; import { Status } from './Status'; import { Messages } from './Messages'; @@ -28,17 +30,23 @@ export const TaskDrawer = ({ task, onClose }: TaskDrawerProps) => { queryFn: () => getMessages(task.taskId), }); + const { data: orgSettings } = useOrganizationSettings(); + + const isTaskSharingEnabled = + orgSettings?.cloudSettings?.enableTaskSharing ?? false; + return ( - {task.title || generateFallbackTitle(task)} - {task.taskId} -
+
+ {isTaskSharingEnabled && }
+ {task.title || generateFallbackTitle(task)} + {task.taskId}
diff --git a/src/app/api/ping/route.ts b/src/app/api/ping/route.ts deleted file mode 100644 index e27943b52e..0000000000 --- a/src/app/api/ping/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { auth } from '@clerk/nextjs/server'; -import { NextResponse } from 'next/server'; - -import { logger } from '@/lib/server/logger'; - -/** - * API endpoint for testing Clerk authentication. - * Verifies/parses JWT and logs authenticated user information. - */ -export async function GET() { - const authObj = await auth(); - - if (!authObj.userId) { - return NextResponse.json( - { error: 'Unauthorized request' }, - { status: 401 }, - ); - } - - // Get the JWT token. - const token = await authObj.getToken(); - - // Extract user information from the auth object. - // Only include properties that exist on the auth object. - // Note: To get additional user data like email, firstName, lastName, - // you would need to use Clerk's methods like clerkClient.users.getUser(). - const { userId, sessionId, orgId, orgRole } = authObj; - const userInfo = { userId, sessionId, orgId, orgRole }; - - // Just log if token exists, not the actual token for security. - logger.info({ event: 'ping_endpoint_accessed', userInfo, hasToken: !!token }); - - return NextResponse.json(userInfo); -} diff --git a/src/components/layout/NavbarMenu.tsx b/src/components/layout/NavbarMenu.tsx index afabd3cbdf..a23bc58221 100644 --- a/src/components/layout/NavbarMenu.tsx +++ b/src/components/layout/NavbarMenu.tsx @@ -18,7 +18,7 @@ const tabValues = [ '/usage', '/audit-logs', '/providers', - '/telemetry', + '/settings', '/org', '/hidden', ] as const; @@ -62,7 +62,7 @@ export const NavbarMenu = ({ <> Audit Logs Providers - Telemetry + Settings Organization )} diff --git a/src/components/task-sharing/ShareButton.tsx b/src/components/task-sharing/ShareButton.tsx new file mode 100644 index 0000000000..75966802fc --- /dev/null +++ b/src/components/task-sharing/ShareButton.tsx @@ -0,0 +1,227 @@ +'use client'; + +import { useState } from 'react'; +import { Copy, Share, Trash2, ExternalLink } from 'lucide-react'; +import { toast } from 'sonner'; + +import type { TaskShare } from '@/db'; +import type { TaskWithUser } from '@/actions/analytics'; +import { + createTaskShare, + deleteTaskShare, + getTaskShares, +} from '@/actions/taskSharing'; +import { + createShareUrl, + DEFAULT_SHARE_EXPIRATION_DAYS, +} from '@/lib/task-sharing'; +import { copyToClipboard } from '@/lib/clipboard'; +import { useOrganizationSettings } from '@/hooks/useOrganizationSettings'; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui'; + +type ShareButtonProps = { + task: TaskWithUser; +}; + +export const ShareButton = ({ task }: ShareButtonProps) => { + const [isOpen, setIsOpen] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [shares, setShares] = useState([]); + const [newShareUrl, setNewShareUrl] = useState(null); + + const { data: orgSettings } = useOrganizationSettings(); + + const expirationDays = + orgSettings?.cloudSettings?.taskShareExpirationDays ?? + DEFAULT_SHARE_EXPIRATION_DAYS; + + const loadShares = async () => { + try { + const taskShares = await getTaskShares(task.taskId); + setShares(taskShares); + } catch (error) { + console.error('Error loading shares:', error); + toast.error('Failed to load existing shares'); + } + }; + + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + if (open) { + loadShares(); + setNewShareUrl(null); + } + }; + + const handleCreateShare = async () => { + setIsCreating(true); + try { + const response = await createTaskShare({ taskId: task.taskId }); + + if (response.success && response.data) { + setNewShareUrl(response.data.shareUrl); + // Automatically copy the link to clipboard + await handleCopyLink(response.data.shareUrl); + toast.success('Share link created and copied to clipboard!'); + await loadShares(); // Refresh the shares list + } else { + toast.error(response.error || 'Failed to create share link'); + } + } catch (error) { + console.error('Error creating share:', error); + toast.error('Failed to create share link'); + } finally { + setIsCreating(false); + } + }; + + const handleCopyLink = async (url: string) => { + const success = await copyToClipboard(url); + if (success) { + toast.success('Link copied to clipboard'); + } else { + toast.error('Failed to copy link'); + } + }; + + const handleDeleteShare = async (shareId: string) => { + try { + const response = await deleteTaskShare(shareId); + + if (response.success) { + toast.success('Share link deleted successfully'); + await loadShares(); // Refresh the shares list + if (newShareUrl) { + setNewShareUrl(null); // Clear the new share URL if it was deleted + } + } else { + toast.error(response.error || 'Failed to delete share link'); + } + } catch (error) { + console.error('Error deleting share:', error); + toast.error('Failed to delete share link'); + } + }; + + return ( + + + + + + + Share Task + + Create a link to share this task with your team. + + + +
+ {/* Create New Share */} + {newShareUrl ? ( +
+
+

Share link created!

+
+ + +
+
+
+ ) : ( + + )} + + {/* Existing Shares */} + {shares.length > 0 && ( +
+

+ Previous Links ({shares.length}) +

+
+ {shares.slice(0, 3).map((share) => { + const shareUrl = createShareUrl(share.shareToken); + return ( +
+
+

+ Created{' '} + {new Date(share.createdAt).toLocaleDateString()} +

+
+
+ + +
+
+ ); + })} + {shares.length > 3 && ( +

+ +{shares.length - 3} more links +

+ )} +
+
+ )} + + {/* Simple Info */} +

+ Links expire in {expirationDays} days and are only accessible to + your organization members. +

+
+
+
+ ); +}; diff --git a/src/components/task-sharing/SharedTaskView.tsx b/src/components/task-sharing/SharedTaskView.tsx new file mode 100644 index 0000000000..cc9aa34b1d --- /dev/null +++ b/src/components/task-sharing/SharedTaskView.tsx @@ -0,0 +1,81 @@ +import type { TaskWithUser } from '@/actions/analytics'; +import type { Message } from '@/types/analytics'; +import { formatCurrency, formatNumber } from '@/lib/formatters'; +import { generateFallbackTitle } from '@/lib/task-utils'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'; +import { Status } from '@/app/(authenticated)/usage/Status'; +import { Messages } from '@/app/(authenticated)/usage/Messages'; + +type SharedTaskViewProps = { + task: TaskWithUser; + messages: Message[]; +}; + +export const SharedTaskView = ({ task, messages }: SharedTaskViewProps) => { + const taskTitle = task.title || generateFallbackTitle(task); + + return ( +
+ {/* Task Header */} + + +
+
+ {taskTitle} +

+ Shared by {task.user.name} •{' '} + {new Date(task.timestamp * 1000).toLocaleDateString()} +

+
+
+ + + Shared + +
+
+
+ +
+
+

Model

+

{task.model}

+
+
+

Provider

+

{task.provider}

+
+
+

Tokens

+

{formatNumber(task.tokens)}

+
+
+

Cost

+

{formatCurrency(task.cost)}

+
+
+
+
+ + {/* Conversation */} + {messages.length > 0 ? ( + + + Conversation + + + + + + ) : ( + + +

+ No conversation messages are available for this task. +

+
+
+ )} +
+ ); +}; diff --git a/src/components/task-sharing/index.ts b/src/components/task-sharing/index.ts new file mode 100644 index 0000000000..91322d4faf --- /dev/null +++ b/src/components/task-sharing/index.ts @@ -0,0 +1,2 @@ +export * from './ShareButton'; +export * from './SharedTaskView'; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 0017550143..8b74d2277f 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -3,6 +3,7 @@ export * from './button'; export * from './command'; export * from './card'; export * from './checkbox'; +export * from './dialog'; export * from './drawer'; export * from './dropdown-menu'; export * from './form'; diff --git a/src/components/usage/TaskCard.tsx b/src/components/usage/TaskCard.tsx index b5ed1d3ca1..dcd10bdcd7 100644 --- a/src/components/usage/TaskCard.tsx +++ b/src/components/usage/TaskCard.tsx @@ -3,7 +3,7 @@ import { formatCurrency, formatTimestamp, } from '@/lib/formatters'; -import { generateFallbackTitle } from '@/lib/taskUtils'; +import { generateFallbackTitle } from '@/lib/task-utils'; import { Card, CardContent, diff --git a/src/components/usage/UsageChart.tsx b/src/components/usage/UsageChart.tsx index a22fcce816..9a346e99fa 100644 --- a/src/components/usage/UsageChart.tsx +++ b/src/components/usage/UsageChart.tsx @@ -16,7 +16,7 @@ import { import type { TimePeriodConfig } from '@/types'; import { getHourlyUsageByUser } from '@/actions/analytics'; import { formatNumber } from '@/lib/formatters'; -import { aggregateHourlyToDaily } from '@/lib/timezoneUtils'; +import { aggregateHourlyToDaily } from '@/lib/timezone-utils'; type MetricType = 'tasks' | 'tokens' | 'cost'; interface TickProps { diff --git a/src/db/types.ts b/src/db/types.ts index 0d627458e5..3faee65bd8 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -1,4 +1,4 @@ -import type { users, orgs, orgSettings, auditLogs } from './schema'; +import type { users, orgs, orgSettings, auditLogs, taskShares } from './schema'; type Generated = 'id' | 'createdAt' | 'updatedAt'; @@ -40,3 +40,11 @@ export type CreateAuditLog = Omit; export type AuditLogWithUser = AuditLog & { user: User; }; + +/** + * taskShares + */ + +export type TaskShare = typeof taskShares.$inferSelect; + +export type CreateTaskShare = Omit; diff --git a/src/lib/__tests__/task-sharing.test.ts b/src/lib/__tests__/task-sharing.test.ts new file mode 100644 index 0000000000..969470efe7 --- /dev/null +++ b/src/lib/__tests__/task-sharing.test.ts @@ -0,0 +1,98 @@ +import { + isValidShareToken, + isShareExpired, + calculateExpirationDate, + createShareUrl, + DEFAULT_SHARE_EXPIRATION_DAYS, +} from '../task-sharing'; +import { generateShareToken } from '../server/task-sharing'; + +describe('taskSharing utilities', () => { + describe('generateShareToken', () => { + it('should generate a valid UUID token', () => { + const token = generateShareToken(); + expect(token).toBeDefined(); + expect(typeof token).toBe('string'); + expect(isValidShareToken(token)).toBe(true); + }); + + it('should generate unique tokens', () => { + const token1 = generateShareToken(); + const token2 = generateShareToken(); + expect(token1).not.toBe(token2); + }); + }); + + describe('isValidShareToken', () => { + it('should validate correct UUID format', () => { + const validToken = generateShareToken(); + expect(isValidShareToken(validToken)).toBe(true); + }); + + it('should reject invalid token formats', () => { + expect(isValidShareToken('invalid-token')).toBe(false); + expect(isValidShareToken('123')).toBe(false); + expect(isValidShareToken('')).toBe(false); + expect(isValidShareToken('not-a-uuid-at-all')).toBe(false); + }); + }); + + describe('isShareExpired', () => { + it('should return false for null expiration', () => { + expect(isShareExpired(null)).toBe(false); + }); + + it('should return true for past dates', () => { + const pastDate = new Date('2020-01-01'); + expect(isShareExpired(pastDate)).toBe(true); + }); + + it('should return false for future dates', () => { + const futureDate = new Date('2030-01-01'); + expect(isShareExpired(futureDate)).toBe(false); + }); + }); + + describe('calculateExpirationDate', () => { + it('should calculate correct expiration date', () => { + const days = 30; + const expirationDate = calculateExpirationDate(days); + const expectedDate = new Date(); + expectedDate.setDate(expectedDate.getDate() + days); + + // Allow for small time differences (within 1 minute) + const timeDiff = Math.abs( + expirationDate.getTime() - expectedDate.getTime(), + ); + expect(timeDiff).toBeLessThan(60000); // 1 minute in milliseconds + }); + + it('should handle different day values', () => { + const expirationDate1 = calculateExpirationDate(1); + const expirationDate7 = calculateExpirationDate(7); + + const daysDiff = + (expirationDate7.getTime() - expirationDate1.getTime()) / + (1000 * 60 * 60 * 24); + expect(Math.round(daysDiff)).toBe(6); + }); + }); + + describe('createShareUrl', () => { + it('should create correct share URL', () => { + const token = generateShareToken(); + const url = createShareUrl(token); + + expect(url).toContain('/share/'); + expect(url).toContain(token); + expect(url).toMatch(/^https?:\/\/.+\/share\/.+$/); + }); + }); + + describe('constants', () => { + it('should have correct default expiration days', () => { + expect(DEFAULT_SHARE_EXPIRATION_DAYS).toBe(30); + expect(typeof DEFAULT_SHARE_EXPIRATION_DAYS).toBe('number'); + }); + }); +}); diff --git a/src/lib/__tests__/timezoneUtils.test.ts b/src/lib/__tests__/timezone-utils.test.ts similarity index 96% rename from src/lib/__tests__/timezoneUtils.test.ts rename to src/lib/__tests__/timezone-utils.test.ts index 2265067057..997f2de79d 100644 --- a/src/lib/__tests__/timezoneUtils.test.ts +++ b/src/lib/__tests__/timezone-utils.test.ts @@ -1,4 +1,4 @@ -import { aggregateHourlyToDaily } from '../timezoneUtils'; +import { aggregateHourlyToDaily } from '../timezone-utils'; import { HourlyUsageByUser } from '@/actions/analytics/events'; // Mock timezone to ensure consistent test results @@ -95,7 +95,7 @@ describe('timezoneUtils', () => { }); it('should handle empty data', () => { - const result = aggregateHourlyToDaily([]); + const result = aggregateHourlyToDaily([], mockTimezone); expect(result).toEqual([]); }); diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts new file mode 100644 index 0000000000..422978d9d1 --- /dev/null +++ b/src/lib/clipboard.ts @@ -0,0 +1,32 @@ +/** + * Copy text to clipboard with fallback for older browsers + */ +export async function copyToClipboard(text: string): Promise { + try { + // Try modern clipboard API first + await navigator.clipboard.writeText(text); + return true; + } catch (error) { + console.error('Modern clipboard API failed:', error); + + // Fallback for older browsers or when clipboard API fails + try { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + const successful = document.execCommand('copy'); + document.body.removeChild(textArea); + + return successful; + } catch (fallbackError) { + console.error('Fallback copy failed:', fallbackError); + return false; + } + } +} diff --git a/src/lib/server/index.ts b/src/lib/server/index.ts index 5072d0410d..a101d4b424 100644 --- a/src/lib/server/index.ts +++ b/src/lib/server/index.ts @@ -2,3 +2,4 @@ export { Env } from './env'; export { logger } from './logger'; export { isAuthSuccess, handleError } from './api'; export { analytics } from './analytics'; +export { generateShareToken } from './task-sharing'; diff --git a/src/lib/server/task-sharing.ts b/src/lib/server/task-sharing.ts new file mode 100644 index 0000000000..95bd8f2d63 --- /dev/null +++ b/src/lib/server/task-sharing.ts @@ -0,0 +1,5 @@ +import { randomUUID } from 'crypto'; + +export function generateShareToken() { + return randomUUID(); +} diff --git a/src/lib/task-sharing.ts b/src/lib/task-sharing.ts new file mode 100644 index 0000000000..afa3705fde --- /dev/null +++ b/src/lib/task-sharing.ts @@ -0,0 +1,24 @@ +import { shareIdSchema } from '@/types'; + +export function isValidShareToken(token: string): boolean { + return shareIdSchema.safeParse(token).success; +} + +export function isShareExpired(expiresAt: Date | null): boolean { + return expiresAt ? new Date() > expiresAt : false; +} + +export function calculateExpirationDate(days: number): Date { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + days); + return expirationDate; +} + +export function createShareUrl(token: string): string { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; + return `${baseUrl}/share/${token}`; +} + +export const DEFAULT_SHARE_EXPIRATION_DAYS = 30; + +export const MAX_SHARE_EXPIRATION_DAYS = 365; diff --git a/src/lib/taskUtils.ts b/src/lib/task-utils.ts similarity index 100% rename from src/lib/taskUtils.ts rename to src/lib/task-utils.ts diff --git a/src/lib/timezoneUtils.ts b/src/lib/timezone-utils.ts similarity index 100% rename from src/lib/timezoneUtils.ts rename to src/lib/timezone-utils.ts diff --git a/src/types/index.ts b/src/types/index.ts index 95b277761b..0df99360eb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,5 @@ export * from './api'; export * from './auth'; export * from './react-query'; +export * from './task-sharing'; export * from './time-period'; diff --git a/src/types/task-sharing.ts b/src/types/task-sharing.ts new file mode 100644 index 0000000000..6cd47cab8f --- /dev/null +++ b/src/types/task-sharing.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const createTaskShareSchema = z.object({ + taskId: z.string().min(1, 'Task ID is required'), + expirationDays: z.number().int().positive().max(365).optional(), +}); + +export type CreateTaskShareRequest = z.infer; + +export const shareIdSchema = z.string().uuid('Invalid share ID format'); + +export type ShareId = z.infer;