From 9eeae3071de92fd4d9b8da7e903d144bbf6a59f9 Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Tue, 3 Jun 2025 11:28:34 -0700 Subject: [PATCH] Revert "Task sharing (#71)" (#73) --- src/actions/taskSharing.ts | 328 ------------------ .../(authenticated)/settings/SettingsForm.tsx | 187 ---------- .../(authenticated)/settings/SettingsPage.tsx | 25 -- src/app/(authenticated)/settings/page.tsx | 10 - .../(authenticated)/share/[token]/page.tsx | 89 ----- .../telemetry/TelemetryForm.tsx | 111 ++++++ .../telemetry/TelemetrySettings.tsx | 31 ++ src/app/(authenticated)/telemetry/page.tsx | 5 + src/app/(authenticated)/usage/TaskDrawer.tsx | 14 +- src/app/api/organization-settings/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/lib/__tests__/taskSharing.test.ts | 99 ------ src/lib/__tests__/timezoneUtils.test.ts | 2 +- src/lib/clipboard.ts | 32 -- src/lib/schemas/taskSharing.ts | 16 - src/lib/server/taskSharing.ts | 10 - src/lib/taskSharing.ts | 48 --- src/types/org.ts | 7 +- 22 files changed, 188 insertions(+), 1175 deletions(-) delete mode 100644 src/actions/taskSharing.ts delete mode 100644 src/app/(authenticated)/settings/SettingsForm.tsx delete mode 100644 src/app/(authenticated)/settings/SettingsPage.tsx delete mode 100644 src/app/(authenticated)/settings/page.tsx delete mode 100644 src/app/(authenticated)/share/[token]/page.tsx create mode 100644 src/app/(authenticated)/telemetry/TelemetryForm.tsx create mode 100644 src/app/(authenticated)/telemetry/TelemetrySettings.tsx create mode 100644 src/app/(authenticated)/telemetry/page.tsx create mode 100644 src/app/api/organization-settings/route.ts delete mode 100644 src/components/task-sharing/ShareButton.tsx delete mode 100644 src/components/task-sharing/SharedTaskView.tsx delete mode 100644 src/components/task-sharing/index.ts delete mode 100644 src/lib/__tests__/taskSharing.test.ts delete mode 100644 src/lib/clipboard.ts delete mode 100644 src/lib/schemas/taskSharing.ts delete mode 100644 src/lib/server/taskSharing.ts delete mode 100644 src/lib/taskSharing.ts diff --git a/src/actions/taskSharing.ts b/src/actions/taskSharing.ts deleted file mode 100644 index 742bd7ef56..0000000000 --- a/src/actions/taskSharing.ts +++ /dev/null @@ -1,328 +0,0 @@ -'use server'; - -import { eq, and, sql, desc } from 'drizzle-orm'; -import { auth } from '@clerk/nextjs/server'; - -import type { ApiResponse } from '@/types'; -import { AuditLogTargetType, client as db, taskShares } from '@/db/server'; -import { handleError, isAuthSuccess } from '@/lib/server'; -import { - isValidShareToken, - isShareExpired, - calculateExpirationDate, - createShareUrl, - DEFAULT_SHARE_EXPIRATION_DAYS, -} from '@/lib/taskSharing'; -import { generateShareToken } from '@/lib/server/taskSharing'; -import { - createTaskShareSchema, - shareIdSchema, - type CreateTaskShareRequest, -} from '@/lib/schemas/taskSharing'; -import type { TaskWithUser } from '@/actions/analytics'; -import type { Message } from '@/types/analytics'; -import { getTasks, getMessages } from '@/actions/analytics'; - -import { validateAuth } from './auth'; -import { insertAuditLog } from './auditLogs'; -import { getOrganizationSettings } from './organizationSettings'; - -/** - * Extended API response type for task sharing - */ -type TaskShareResponse = ApiResponse & { - data?: { - shareUrl: string; - shareId: string; - expiresAt: Date | null; - }; -}; - -export type TaskShare = typeof taskShares.$inferSelect; - -/** - * Create a shareable link for a task - */ -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; - - // Get organization settings to check if task sharing is enabled - const orgSettingsData = await getOrganizationSettings(); - if (!orgSettingsData.cloudSettings?.enableTaskSharing) { - return { - success: false, - error: 'Task sharing is not enabled for this organization', - }; - } - - // Verify the user has access to this task - 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', - }; - } - - // Calculate expiration date - const expirationDaysToUse = - expirationDays || - orgSettingsData.cloudSettings?.taskShareExpirationDays || - DEFAULT_SHARE_EXPIRATION_DAYS; - - const expiresAt = calculateExpirationDate(expirationDaysToUse); - const shareToken = generateShareToken(); - - // Create the share record - 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'); - } - - // Log the share creation - 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'); - } - - // Validate token format - if (!isValidShareToken(token)) { - return null; - } - - // Find the share record (scoped to user's organization) - const [share] = await db - .select() - .from(taskShares) - .where(and(eq(taskShares.shareToken, token), eq(taskShares.orgId, orgId))) - .limit(1); - - if (!share) { - return null; - } - - // Check if share has expired - if (isShareExpired(share.expiresAt)) { - return null; - } - - // Get the task data - const tasks = await getTasks({ orgId: share.orgId }); - const task = tasks.find((t) => t.taskId === share.taskId); - - if (!task) { - return null; - } - - // Get the messages for the task - const messages = await getMessages(share.taskId); - - return { task, messages }; - } catch (error) { - // Log error without exposing sensitive details - 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; - - // Validate share ID format - const shareIdResult = shareIdSchema.safeParse(shareId); - if (!shareIdResult.success) { - return { - success: false, - error: 'Invalid share ID format', - }; - } - - // Find the share record and verify ownership - 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', - }; - } - - // Delete the share record - await db.transaction(async (tx) => { - await tx.delete(taskShares).where(eq(taskShares.id, shareId)); - - // Log the share deletion - 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'); - } - - // Verify the user has access to this task - 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'); - } - - // Get all non-expired shares for this task - 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)); - - // Filter out expired shares - 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 deleted file mode 100644 index f6915b7694..0000000000 --- a/src/app/(authenticated)/settings/SettingsForm.tsx +++ /dev/null @@ -1,187 +0,0 @@ -'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 '@/types'; -import { QueryKey } from '@/types'; -import { updateOrganization } from '@/actions/organizationSettings'; -import { DEFAULT_SHARE_EXPIRATION_DAYS } from '@/lib/taskSharing'; -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 deleted file mode 100644 index e1e99683e1..0000000000 --- a/src/app/(authenticated)/settings/SettingsPage.tsx +++ /dev/null @@ -1,25 +0,0 @@ -'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 deleted file mode 100644 index 976a68be6e..0000000000 --- a/src/app/(authenticated)/settings/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index d25dbc098b..0000000000 --- a/src/app/(authenticated)/share/[token]/page.tsx +++ /dev/null @@ -1,89 +0,0 @@ -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 new file mode 100644 index 0000000000..8c1c9a6c9f --- /dev/null +++ b/src/app/(authenticated)/telemetry/TelemetryForm.tsx @@ -0,0 +1,111 @@ +'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 '@/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 new file mode 100644 index 0000000000..d69fbe14d4 --- /dev/null +++ b/src/app/(authenticated)/telemetry/TelemetrySettings.tsx @@ -0,0 +1,31 @@ +'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 new file mode 100644 index 0000000000..242deeda83 --- /dev/null +++ b/src/app/(authenticated)/telemetry/page.tsx @@ -0,0 +1,5 @@ +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 90e612a69a..6514228d33 100644 --- a/src/app/(authenticated)/usage/TaskDrawer.tsx +++ b/src/app/(authenticated)/usage/TaskDrawer.tsx @@ -3,7 +3,6 @@ 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 { @@ -14,7 +13,6 @@ import { DrawerTitle, Button, } from '@/components/ui'; -import { ShareButton } from '@/components/task-sharing/ShareButton'; import { Status } from './Status'; import { Messages } from './Messages'; @@ -30,23 +28,17 @@ export const TaskDrawer = ({ task, onClose }: TaskDrawerProps) => { queryFn: () => getMessages(task.taskId), }); - const { data: orgSettings } = useOrganizationSettings(); - - const isTaskSharingEnabled = - orgSettings?.cloudSettings?.enableTaskSharing ?? false; - return ( -
- {isTaskSharingEnabled && } + {task.title || generateFallbackTitle(task)} + {task.taskId} +
- {task.title || generateFallbackTitle(task)} - {task.taskId}
diff --git a/src/app/api/organization-settings/route.ts b/src/app/api/organization-settings/route.ts new file mode 100644 index 0000000000..4b24a8b20d --- /dev/null +++ b/src/app/api/organization-settings/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@clerk/nextjs/server'; + +import { getOrganizationSettings } from '@/actions/organizationSettings'; + +export async function GET() { + try { + const { userId, orgId } = await auth(); + + if (!userId) { + return NextResponse.json( + { error: 'Unauthorized request' }, + { status: 401 }, + ); + } + + if (!orgId) { + return NextResponse.json( + { error: 'Organization not found' }, + { status: 404 }, + ); + } + + const settings = await getOrganizationSettings(); + + return NextResponse.json(settings); + } catch (error) { + console.error('Error fetching organization settings:', error); + return NextResponse.json( + { error: 'Failed to fetch organization settings' }, + { status: 500 }, + ); + } +} diff --git a/src/components/layout/NavbarMenu.tsx b/src/components/layout/NavbarMenu.tsx index a23bc58221..afabd3cbdf 100644 --- a/src/components/layout/NavbarMenu.tsx +++ b/src/components/layout/NavbarMenu.tsx @@ -18,7 +18,7 @@ const tabValues = [ '/usage', '/audit-logs', '/providers', - '/settings', + '/telemetry', '/org', '/hidden', ] as const; @@ -62,7 +62,7 @@ export const NavbarMenu = ({ <> Audit Logs Providers - Settings + Telemetry Organization )} diff --git a/src/components/task-sharing/ShareButton.tsx b/src/components/task-sharing/ShareButton.tsx deleted file mode 100644 index 3c152a6d9a..0000000000 --- a/src/components/task-sharing/ShareButton.tsx +++ /dev/null @@ -1,227 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Copy, Share, Trash2, ExternalLink } from 'lucide-react'; -import { toast } from 'sonner'; - -import type { TaskWithUser } from '@/actions/analytics'; -import type { TaskShare } from '@/actions/taskSharing'; -import { - createTaskShare, - deleteTaskShare, - getTaskShares, -} from '@/actions/taskSharing'; -import { useOrganizationSettings } from '@/hooks/useOrganizationSettings'; -import { - createShareUrl, - DEFAULT_SHARE_EXPIRATION_DAYS, -} from '@/lib/taskSharing'; -import { copyToClipboard } from '@/lib/clipboard'; -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 deleted file mode 100644 index 74dd58ffa3..0000000000 --- a/src/components/task-sharing/SharedTaskView.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import type { TaskWithUser } from '@/actions/analytics'; -import type { Message } from '@/types/analytics'; -import { formatCurrency, formatNumber } from '@/lib/formatters'; -import { generateFallbackTitle } from '@/lib/taskUtils'; -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 deleted file mode 100644 index 91322d4faf..0000000000 --- a/src/components/task-sharing/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './ShareButton'; -export * from './SharedTaskView'; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 8b74d2277f..0017550143 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -3,7 +3,6 @@ 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/lib/__tests__/taskSharing.test.ts b/src/lib/__tests__/taskSharing.test.ts deleted file mode 100644 index e04c63af66..0000000000 --- a/src/lib/__tests__/taskSharing.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { - isValidShareToken, - isShareExpired, - calculateExpirationDate, - createShareUrl, - DEFAULT_SHARE_EXPIRATION_DAYS, - UUID_V4_REGEX, -} from '../taskSharing'; -import { generateShareToken } from '../server/taskSharing'; - -describe('taskSharing utilities', () => { - describe('generateShareToken', () => { - it('should generate a valid UUID token', () => { - const token = generateShareToken(); - expect(token).toBeDefined(); - expect(typeof token).toBe('string'); - expect(token).toMatch(UUID_V4_REGEX); - }); - - 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__/timezoneUtils.test.ts index 9242330e13..2265067057 100644 --- a/src/lib/__tests__/timezoneUtils.test.ts +++ b/src/lib/__tests__/timezoneUtils.test.ts @@ -95,7 +95,7 @@ describe('timezoneUtils', () => { }); it('should handle empty data', () => { - const result = aggregateHourlyToDaily([], mockTimezone); + const result = aggregateHourlyToDaily([]); expect(result).toEqual([]); }); diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts deleted file mode 100644 index 422978d9d1..0000000000 --- a/src/lib/clipboard.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * 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/schemas/taskSharing.ts b/src/lib/schemas/taskSharing.ts deleted file mode 100644 index 41c33c0c0f..0000000000 --- a/src/lib/schemas/taskSharing.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { z } from 'zod'; - -/** - * Shared validation schema for task share creation - */ -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; - -/** - * Validation schema for share ID - */ -export const shareIdSchema = z.string().uuid('Invalid share ID format'); diff --git a/src/lib/server/taskSharing.ts b/src/lib/server/taskSharing.ts deleted file mode 100644 index 909481cb95..0000000000 --- a/src/lib/server/taskSharing.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { randomUUID } from 'crypto'; - -/** - * Generate a cryptographically secure share token - * - * @server-only This function uses Node.js crypto module and cannot be imported client-side - */ -export function generateShareToken(): string { - return randomUUID(); -} diff --git a/src/lib/taskSharing.ts b/src/lib/taskSharing.ts deleted file mode 100644 index d2b476c054..0000000000 --- a/src/lib/taskSharing.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * UUID v4 validation regex pattern (RFC 4122 compliant) - * Validates format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx - */ -export const UUID_V4_REGEX = - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - -/** - * Validate that a token is a valid UUID format - */ -export function isValidShareToken(token: string): boolean { - return UUID_V4_REGEX.test(token); -} - -/** - * Check if a share has expired - */ -export function isShareExpired(expiresAt: Date | null): boolean { - if (!expiresAt) return false; - return new Date() > expiresAt; -} - -/** - * Calculate expiration date based on days from now - */ -export function calculateExpirationDate(days: number): Date { - const expirationDate = new Date(); - expirationDate.setDate(expirationDate.getDate() + days); - return expirationDate; -} - -/** - * Create a share URL from a token - */ -export function createShareUrl(token: string): string { - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'; - return `${baseUrl}/share/${token}`; -} - -/** - * Default expiration days for task shares - */ -export const DEFAULT_SHARE_EXPIRATION_DAYS = 30; - -/** - * Maximum expiration days allowed - */ -export const MAX_SHARE_EXPIRATION_DAYS = 365; diff --git a/src/types/org.ts b/src/types/org.ts index 69f949b9f0..11f3b2e53f 100644 --- a/src/types/org.ts +++ b/src/types/org.ts @@ -42,8 +42,6 @@ export type OrganizationDefaultSettings = z.infer< export const organizationCloudSettingsSchema = z.object({ recordTaskMessages: z.boolean().optional(), - enableTaskSharing: z.boolean().optional(), - taskShareExpirationDays: z.number().int().positive().optional(), }); export type OrganizationCloudSettings = z.infer< @@ -61,10 +59,7 @@ export type OrganizationSettings = z.infer; export const ORGANIZATION_DEFAULT: OrganizationSettings = { version: 0, - cloudSettings: { - enableTaskSharing: true, - taskShareExpirationDays: 30, - }, + cloudSettings: {}, defaultSettings: {}, allowList: ORGANIZATION_ALLOW_ALL, } as const;