mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
parent
5bc3438212
commit
9eeae3071d
22 changed files with 188 additions and 1175 deletions
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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's settings and preferences.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
{orgSettings ? <SettingsForm orgSettings={orgSettings} /> : <Loading />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { SettingsPage } from './SettingsPage';
|
||||
|
||||
export default function Settings() {
|
||||
return <SettingsPage />;
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: 'Settings',
|
||||
description: 'Configure your organization settings',
|
||||
};
|
||||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
111
src/app/(authenticated)/telemetry/TelemetryForm.tsx
Normal file
111
src/app/(authenticated)/telemetry/TelemetryForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
31
src/app/(authenticated)/telemetry/TelemetrySettings.tsx
Normal file
31
src/app/(authenticated)/telemetry/TelemetrySettings.tsx
Normal 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 />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
5
src/app/(authenticated)/telemetry/page.tsx
Normal file
5
src/app/(authenticated)/telemetry/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { TelemetrySettings } from './TelemetrySettings';
|
||||
|
||||
export default function Page() {
|
||||
return <TelemetrySettings />;
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
34
src/app/api/organization-settings/route.ts
Normal file
34
src/app/api/organization-settings/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from './ShareButton';
|
||||
export * from './SharedTaskView';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -95,7 +95,7 @@ describe('timezoneUtils', () => {
|
|||
});
|
||||
|
||||
it('should handle empty data', () => {
|
||||
const result = aggregateHourlyToDaily([], mockTimezone);
|
||||
const result = aggregateHourlyToDaily([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue