Revert "Task sharing (#71)" (#73)

This commit is contained in:
Chris Estreich 2025-06-03 11:28:34 -07:00 committed by GitHub
parent 5bc3438212
commit 9eeae3071d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 188 additions and 1175 deletions

View file

@ -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<TaskShareResponse> {
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<ApiResponse> {
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<TaskShare[]> {
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 };
}
}

View file

@ -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<FormData>({
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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* Task Recording Section */}
<div className="space-y-4 rounded-lg p-4">
<div className="flex items-center gap-2">
<Cloud className="size-5" />
<h2 className="text-lg font-medium">Task Recording</h2>
</div>
<div className="mt-4 space-y-4">
<FormField
control={form.control}
name="recordTaskMessages"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
disabled={isSaving}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Record task messages</FormLabel>
<FormDescription>
When enabled, task messages and interactions will be
recorded.
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
</div>
{/* Task Sharing Section */}
<div className="space-y-4 rounded-lg p-4">
<div className="flex items-center gap-2">
<Share className="size-5" />
<h2 className="text-lg font-medium">Task Sharing</h2>
</div>
<div className="mt-4 space-y-4">
<FormField
control={form.control}
name="enableTaskSharing"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
disabled={isSaving}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Enable task sharing</FormLabel>
<FormDescription>
Allow users to create shareable links for tasks that can
be viewed by other organization members.
</FormDescription>
</div>
</FormItem>
)}
/>
{form.watch('enableTaskSharing') && (
<FormField
control={form.control}
name="taskShareExpirationDays"
render={({ field }) => (
<FormItem className="rounded-md border p-4">
<FormLabel>Share Link Expiration (Days)</FormLabel>
<FormControl>
<Input
type="number"
min="1"
max="365"
{...field}
onChange={(e) => {
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"
/>
</FormControl>
<FormDescription>
Number of days before shared links expire (1-365 days).
Default is {DEFAULT_SHARE_EXPIRATION_DAYS} days.
</FormDescription>
</FormItem>
)}
/>
)}
</div>
</div>
<div className="flex justify-end space-x-4 border-t pt-4">
<Button type="submit" disabled={isSaving}>
{isSaving ? <Loading /> : 'Save Changes'}
</Button>
</div>
</form>
</Form>
);
};

View file

@ -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 (
<>
<Card>
<CardHeader>
<CardTitle>Organization Settings</CardTitle>
<CardDescription>
Configure your organization&apos;s settings and preferences.
</CardDescription>
</CardHeader>
</Card>
{orgSettings ? <SettingsForm orgSettings={orgSettings} /> : <Loading />}
</>
);
};

View file

@ -1,10 +0,0 @@
import { SettingsPage } from './SettingsPage';
export default function Settings() {
return <SettingsPage />;
}
export const metadata = {
title: 'Settings',
description: 'Configure your organization settings',
};

View file

@ -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 (
<div className="container mx-auto py-6">
<div className="mb-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Shared Task</span>
</div>
</div>
<SharedTaskView task={task} messages={messages} />
</div>
);
} 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 (
<div className="container mx-auto py-6">
<div className="text-center">
<h1 className="text-2xl font-bold text-destructive mb-4">
Access Denied
</h1>
<p className="text-muted-foreground mb-4">
You must be a member of the organization to view this shared task.
</p>
<p className="text-sm text-muted-foreground">
Please contact the person who shared this link to ensure you have
the correct organization access.
</p>
</div>
</div>
);
}
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',
};
}
}

View file

@ -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<FormData>({
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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="space-y-4 rounded-lg p-4">
<div className="flex items-center gap-2">
<Cloud className="size-5" />
<h2 className="text-lg font-medium">Task Recording</h2>
</div>
<div className="mt-4 space-y-4">
<FormField
control={form.control}
name="recordTaskMessages"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
disabled={isSaving}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>{t('record_task_messages')}</FormLabel>
<FormDescription>
{t('record_task_messages_description')}
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
</div>
<div className="flex justify-end space-x-4 border-t pt-4">
<Button type="submit" disabled={isSaving}>
{isSaving ? <Loading /> : 'Save Changes'}
</Button>
</div>
</form>
</Form>
);
};

View file

@ -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 (
<>
<Card>
<CardHeader>
<CardTitle>{t('title')}</CardTitle>
<CardDescription>{t('description')}</CardDescription>
</CardHeader>
</Card>
{orgSettings ? <TelemetryForm orgSettings={orgSettings} /> : <Loading />}
</>
);
};

View file

@ -0,0 +1,5 @@
import { TelemetrySettings } from './TelemetrySettings';
export default function Page() {
return <TelemetrySettings />;
}

View file

@ -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 (
<Drawer open={true} onOpenChange={onClose} direction="right">
<DrawerContent className="flex flex-col h-full">
<DrawerHeader className="flex-shrink-0">
<div className="flex justify-end gap-2 mb-4">
{isTaskSharingEnabled && <ShareButton task={task} />}
<DrawerTitle>{task.title || generateFallbackTitle(task)}</DrawerTitle>
<DrawerDescription>{task.taskId}</DrawerDescription>
<div className="absolute top-2 right-2">
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="size-4" />
</Button>
</div>
<DrawerTitle>{task.title || generateFallbackTitle(task)}</DrawerTitle>
<DrawerDescription>{task.taskId}</DrawerDescription>
</DrawerHeader>
<div className="flex-1 overflow-y-auto p-4">
<div className="mb-6 space-y-2">

View file

@ -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 },
);
}
}

View file

@ -18,7 +18,7 @@ const tabValues = [
'/usage',
'/audit-logs',
'/providers',
'/settings',
'/telemetry',
'/org',
'/hidden',
] as const;
@ -62,7 +62,7 @@ export const NavbarMenu = ({
<>
<TabsTrigger value="/audit-logs">Audit Logs</TabsTrigger>
<TabsTrigger value="/providers">Providers</TabsTrigger>
<TabsTrigger value="/settings">Settings</TabsTrigger>
<TabsTrigger value="/telemetry">Telemetry</TabsTrigger>
<TabsTrigger value="/org">Organization</TabsTrigger>
</>
)}

View file

@ -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<TaskShare[]>([]);
const [newShareUrl, setNewShareUrl] = useState<string | null>(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 (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Share className="size-4 mr-2" />
Share
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Share Task</DialogTitle>
<DialogDescription>
Create a link to share this task with your team.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Create New Share */}
{newShareUrl ? (
<div className="space-y-3">
<div className="p-3 border rounded-md">
<p className="text-sm font-medium mb-2">Share link created!</p>
<div className="flex gap-2">
<Button
onClick={(e) => {
e.preventDefault();
handleCopyLink(newShareUrl);
}}
size="sm"
className="flex-1"
type="button"
>
<Copy className="size-4 mr-2" />
Copy Link
</Button>
<Button
variant="outline"
size="sm"
onClick={() => window.open(newShareUrl, '_blank')}
type="button"
>
<ExternalLink className="size-4" />
</Button>
</div>
</div>
</div>
) : (
<Button
onClick={handleCreateShare}
disabled={isCreating}
className="w-full"
>
{isCreating ? 'Creating...' : 'Create Share Link'}
</Button>
)}
{/* Existing Shares */}
{shares.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2">
Previous Links ({shares.length})
</h4>
<div className="space-y-2">
{shares.slice(0, 3).map((share) => {
const shareUrl = createShareUrl(share.shareToken);
return (
<div
key={share.id}
className="flex items-center justify-between p-2 border rounded"
>
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground truncate">
Created{' '}
{new Date(share.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyLink(shareUrl)}
>
<Copy className="size-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteShare(share.id)}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
);
})}
{shares.length > 3 && (
<p className="text-xs text-muted-foreground text-center">
+{shares.length - 3} more links
</p>
)}
</div>
</div>
)}
{/* Simple Info */}
<p className="text-xs text-muted-foreground">
Links expire in {expirationDays} days and are only accessible to
your organization members.
</p>
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -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 (
<div className="max-w-4xl mx-auto space-y-6">
{/* Task Header */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-xl">{taskTitle}</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
Shared by {task.user.name} {' '}
{new Date(task.timestamp * 1000).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-2">
<Status completed={task.completed} />
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
Shared
</span>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Model</p>
<p className="font-mono">{task.model}</p>
</div>
<div>
<p className="text-muted-foreground">Provider</p>
<p className="font-mono">{task.provider}</p>
</div>
<div>
<p className="text-muted-foreground">Tokens</p>
<p className="font-mono">{formatNumber(task.tokens)}</p>
</div>
<div>
<p className="text-muted-foreground">Cost</p>
<p className="font-mono">{formatCurrency(task.cost)}</p>
</div>
</div>
</CardContent>
</Card>
{/* Conversation */}
{messages.length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Conversation</CardTitle>
</CardHeader>
<CardContent>
<Messages messages={messages} />
</CardContent>
</Card>
) : (
<Card>
<CardContent className="text-center py-12">
<p className="text-muted-foreground">
No conversation messages are available for this task.
</p>
</CardContent>
</Card>
)}
</div>
);
};

View file

@ -1,2 +0,0 @@
export * from './ShareButton';
export * from './SharedTaskView';

View file

@ -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';

View file

@ -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');
});
});
});

View file

@ -95,7 +95,7 @@ describe('timezoneUtils', () => {
});
it('should handle empty data', () => {
const result = aggregateHourlyToDaily([], mockTimezone);
const result = aggregateHourlyToDaily([]);
expect(result).toEqual([]);
});

View file

@ -1,32 +0,0 @@
/**
* Copy text to clipboard with fallback for older browsers
*/
export async function copyToClipboard(text: string): Promise<boolean> {
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;
}
}
}

View file

@ -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<typeof createTaskShareSchema>;
/**
* Validation schema for share ID
*/
export const shareIdSchema = z.string().uuid('Invalid share ID format');

View file

@ -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();
}

View file

@ -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;

View file

@ -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<typeof organizationSettingsSchema>;
export const ORGANIZATION_DEFAULT: OrganizationSettings = {
version: 0,
cloudSettings: {
enableTaskSharing: true,
taskShareExpirationDays: 30,
},
cloudSettings: {},
defaultSettings: {},
allowList: ORGANIZATION_ALLOW_ALL,
} as const;