diff --git a/src/actions/taskSharing.ts b/src/actions/taskSharing.ts new file mode 100644 index 0000000000..742bd7ef56 --- /dev/null +++ b/src/actions/taskSharing.ts @@ -0,0 +1,328 @@ +'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 new file mode 100644 index 0000000000..f6915b7694 --- /dev/null +++ b/src/app/(authenticated)/settings/SettingsForm.tsx @@ -0,0 +1,187 @@ +'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 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 8c1c9a6c9f..0000000000 --- a/src/app/(authenticated)/telemetry/TelemetryForm.tsx +++ /dev/null @@ -1,111 +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 '@/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..90e612a69a 100644 --- a/src/app/(authenticated)/usage/TaskDrawer.tsx +++ b/src/app/(authenticated)/usage/TaskDrawer.tsx @@ -3,6 +3,7 @@ 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 { @@ -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/organization-settings/route.ts b/src/app/api/organization-settings/route.ts deleted file mode 100644 index 4b24a8b20d..0000000000 --- a/src/app/api/organization-settings/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -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 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..3c152a6d9a --- /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 { 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 new file mode 100644 index 0000000000..74dd58ffa3 --- /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/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 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/lib/__tests__/taskSharing.test.ts b/src/lib/__tests__/taskSharing.test.ts new file mode 100644 index 0000000000..e04c63af66 --- /dev/null +++ b/src/lib/__tests__/taskSharing.test.ts @@ -0,0 +1,99 @@ +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 2265067057..9242330e13 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([]); + 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/schemas/taskSharing.ts b/src/lib/schemas/taskSharing.ts new file mode 100644 index 0000000000..41c33c0c0f --- /dev/null +++ b/src/lib/schemas/taskSharing.ts @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000000..909481cb95 --- /dev/null +++ b/src/lib/server/taskSharing.ts @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000000..d2b476c054 --- /dev/null +++ b/src/lib/taskSharing.ts @@ -0,0 +1,48 @@ +/** + * 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 11f3b2e53f..69f949b9f0 100644 --- a/src/types/org.ts +++ b/src/types/org.ts @@ -42,6 +42,8 @@ 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< @@ -59,7 +61,10 @@ export type OrganizationSettings = z.infer; export const ORGANIZATION_DEFAULT: OrganizationSettings = { version: 0, - cloudSettings: {}, + cloudSettings: { + enableTaskSharing: true, + taskShareExpirationDays: 30, + }, defaultSettings: {}, allowList: ORGANIZATION_ALLOW_ALL, } as const;