mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
Personal accounts (#118)
This commit is contained in:
parent
6f3db07df5
commit
9e8588a3a7
21 changed files with 440 additions and 193 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -20,5 +20,5 @@ Thumbs.db
|
|||
.vercel
|
||||
|
||||
# docker
|
||||
.docker/data
|
||||
.docker/logs
|
||||
**/.docker/data
|
||||
**/.docker/logs
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
"@radix-ui/react-toast": "^1.2.14",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@roo-code-cloud/db": "workspace:^",
|
||||
"@roo-code/types": "^1.26.0",
|
||||
"@roo-code/types": "^1.28.0",
|
||||
"@sentry/nextjs": "^9.23.0",
|
||||
"@t3-oss/env-nextjs": "^0.13.6",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,14 @@ export async function createAgent(
|
|||
|
||||
const { userId, orgId, orgRole } = authResult;
|
||||
|
||||
// Agents are only available for organizations, not personal accounts
|
||||
if (!orgId || !orgRole) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Agents are only available for organization accounts.',
|
||||
};
|
||||
}
|
||||
|
||||
if (orgRole !== 'org:admin') {
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -105,6 +113,14 @@ export async function revokeAgent(
|
|||
|
||||
const { orgId, orgRole } = authResult;
|
||||
|
||||
// Agents are only available for organizations, not personal accounts
|
||||
if (!orgId || !orgRole) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Agents are only available for organization accounts.',
|
||||
};
|
||||
}
|
||||
|
||||
if (orgRole !== 'org:admin') {
|
||||
return {
|
||||
success: false,
|
||||
|
|
|
|||
|
|
@ -92,17 +92,24 @@ export const getUsage = async ({
|
|||
requestedUserId: userId,
|
||||
});
|
||||
|
||||
if (!orgId) {
|
||||
return {};
|
||||
// For personal accounts, query by userId instead of orgId
|
||||
if (!orgId && !effectiveUserId) {
|
||||
return {}; // Personal accounts must have a userId
|
||||
}
|
||||
|
||||
const userFilter = effectiveUserId ? 'AND userId = {userId: String}' : '';
|
||||
|
||||
// Build query conditions based on account type
|
||||
const orgCondition = !orgId ? 'orgId IS NULL' : 'orgId = {orgId: String}';
|
||||
|
||||
const queryParams: Record<string, string | number> = {
|
||||
orgId: orgId!,
|
||||
timePeriod,
|
||||
};
|
||||
|
||||
if (orgId) {
|
||||
queryParams.orgId = orgId;
|
||||
}
|
||||
|
||||
if (effectiveUserId) {
|
||||
queryParams.userId = effectiveUserId;
|
||||
}
|
||||
|
|
@ -117,7 +124,7 @@ export const getUsage = async ({
|
|||
SUM(COALESCE(cost, 0)) AS cost
|
||||
FROM events
|
||||
WHERE
|
||||
orgId = {orgId: String}
|
||||
${orgCondition}
|
||||
AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY)
|
||||
${userFilter}
|
||||
GROUP BY 1
|
||||
|
|
@ -332,8 +339,10 @@ export const getTasks = async ({
|
|||
effectiveUserId = authResult.effectiveUserId;
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
return [];
|
||||
// For personal accounts, query by userId instead of orgId
|
||||
// Exception: when skipAuth is true (for public shares), we can query without userId
|
||||
if (!orgId && !effectiveUserId && !skipAuth) {
|
||||
return []; // Personal accounts must have a userId unless we're skipping auth
|
||||
}
|
||||
|
||||
const userFilter = effectiveUserId ? 'AND e.userId = {userId: String}' : '';
|
||||
|
|
@ -345,8 +354,14 @@ export const getTasks = async ({
|
|||
|
||||
const messageTaskFilter = taskId ? 'AND taskId = {taskId: String}' : '';
|
||||
|
||||
// Build query conditions based on account type
|
||||
const orgCondition = !orgId ? 'e.orgId IS NULL' : 'e.orgId = {orgId: String}';
|
||||
|
||||
const messageOrgCondition = !orgId
|
||||
? 'orgId IS NULL'
|
||||
: 'orgId = {orgId: String}';
|
||||
|
||||
const queryParams: Record<string, string | string[]> = {
|
||||
orgId: orgId!,
|
||||
types: [
|
||||
TelemetryEventName.TASK_CREATED,
|
||||
TelemetryEventName.TASK_COMPLETED,
|
||||
|
|
@ -354,6 +369,10 @@ export const getTasks = async ({
|
|||
],
|
||||
};
|
||||
|
||||
if (orgId) {
|
||||
queryParams.orgId = orgId;
|
||||
}
|
||||
|
||||
if (effectiveUserId) {
|
||||
queryParams.userId = effectiveUserId;
|
||||
}
|
||||
|
|
@ -370,7 +389,7 @@ export const getTasks = async ({
|
|||
argMin(text, ts) as title,
|
||||
argMin(mode, ts) as mode
|
||||
FROM messages
|
||||
WHERE orgId = {orgId: String}
|
||||
WHERE ${messageOrgCondition}
|
||||
${messageUserFilter}
|
||||
${messageTaskFilter}
|
||||
GROUP BY taskId
|
||||
|
|
@ -389,7 +408,7 @@ export const getTasks = async ({
|
|||
FROM events e
|
||||
LEFT JOIN first_messages fm ON e.taskId = fm.taskId
|
||||
WHERE
|
||||
e.orgId = {orgId: String}
|
||||
${orgCondition}
|
||||
AND e.type IN ({types: Array(String)})
|
||||
AND e.modelId IS NOT NULL
|
||||
${userFilter}
|
||||
|
|
@ -440,14 +459,17 @@ export const getHourlyUsageByUser = async ({
|
|||
requestedUserId: userId,
|
||||
});
|
||||
|
||||
if (!orgId) {
|
||||
return [];
|
||||
// For personal accounts, query by userId instead of orgId
|
||||
if (!orgId && !effectiveUserId) {
|
||||
return []; // Personal accounts must have a userId
|
||||
}
|
||||
|
||||
const userFilter = effectiveUserId ? 'AND userId = {userId: String}' : '';
|
||||
|
||||
// Build query conditions based on account type
|
||||
const orgCondition = !orgId ? 'orgId IS NULL' : 'orgId = {orgId: String}';
|
||||
|
||||
const queryParams: Record<string, string | number | string[]> = {
|
||||
orgId: orgId!,
|
||||
timePeriod,
|
||||
types: [
|
||||
TelemetryEventName.TASK_CREATED,
|
||||
|
|
@ -456,6 +478,10 @@ export const getHourlyUsageByUser = async ({
|
|||
],
|
||||
};
|
||||
|
||||
if (orgId) {
|
||||
queryParams.orgId = orgId;
|
||||
}
|
||||
|
||||
if (effectiveUserId) {
|
||||
queryParams.userId = effectiveUserId;
|
||||
}
|
||||
|
|
@ -470,7 +496,7 @@ export const getHourlyUsageByUser = async ({
|
|||
SUM(CASE WHEN type = '${TelemetryEventName.LLM_COMPLETION}' THEN COALESCE(cost, 0) ELSE 0 END) AS cost
|
||||
FROM events
|
||||
WHERE
|
||||
orgId = {orgId: String}
|
||||
${orgCondition}
|
||||
AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY)
|
||||
AND type IN ({types: Array(String)})
|
||||
${userFilter}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { analytics } from '@/lib/server';
|
||||
import { authorizeAnalytics } from '@/actions/auth';
|
||||
|
||||
/**
|
||||
* getMessages
|
||||
|
|
@ -26,17 +27,44 @@ const messageSchema = z.object({
|
|||
|
||||
export type Message = z.infer<typeof messageSchema>;
|
||||
|
||||
export const getMessages = async (taskId: string): Promise<Message[]> => {
|
||||
export const getMessages = async (
|
||||
taskId: string,
|
||||
orgId?: string | null,
|
||||
userId?: string | null,
|
||||
skipAuth = false,
|
||||
): Promise<Message[]> => {
|
||||
// Authorize the request - this will handle both personal and org contexts
|
||||
// Skip auth for public shares viewed by unauthenticated users
|
||||
if (!skipAuth) {
|
||||
await authorizeAnalytics({
|
||||
requestedOrgId: orgId,
|
||||
requestedUserId: userId,
|
||||
allowCrossUserAccess: true, // Allow viewing messages within authorized tasks
|
||||
});
|
||||
}
|
||||
|
||||
// For personal accounts, query with orgId IS NULL
|
||||
// For organizations, query with specific orgId
|
||||
const orgCondition = !orgId ? 'orgId IS NULL' : 'orgId = {orgId: String}';
|
||||
|
||||
const queryParams: Record<string, string> = { taskId };
|
||||
if (orgId) {
|
||||
queryParams.orgId = orgId;
|
||||
}
|
||||
|
||||
const results = await analytics.query({
|
||||
query: `
|
||||
SELECT *
|
||||
FROM messages
|
||||
WHERE taskId = {taskId: String}
|
||||
AND ${orgCondition}
|
||||
ORDER BY ts ASC
|
||||
`,
|
||||
format: 'JSONEachRow',
|
||||
query_params: { taskId },
|
||||
query_params: queryParams,
|
||||
});
|
||||
|
||||
return z.array(messageSchema).parse(await results.json());
|
||||
const messages = z.array(messageSchema).parse(await results.json());
|
||||
|
||||
return messages;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,8 +18,15 @@ export async function authorize(): Promise<AuthResult> {
|
|||
return { success: false, error: 'Unauthorized: User required' };
|
||||
}
|
||||
|
||||
// Personal context is valid - no org required
|
||||
if (!orgId) {
|
||||
return { success: false, error: 'Unauthorized: Organization required' };
|
||||
return {
|
||||
success: true,
|
||||
userType: 'user',
|
||||
userId,
|
||||
orgId: null,
|
||||
orgRole: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -111,6 +118,27 @@ export async function authorizeAnalytics({
|
|||
}) {
|
||||
const { orgId: authOrgId, orgRole, userId: authUserId } = await auth();
|
||||
|
||||
if (!authUserId) {
|
||||
throw new Error('Unauthorized: User required');
|
||||
}
|
||||
|
||||
// Handle personal context
|
||||
if (!authOrgId && !requestedOrgId) {
|
||||
// Personal context - user can only access their own data
|
||||
if (requestedUserId && requestedUserId !== authUserId) {
|
||||
throw new Error(
|
||||
'Unauthorized: Personal users can only access their own data',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
authOrgId: null,
|
||||
authUserId,
|
||||
orgRole: null,
|
||||
effectiveUserId: authUserId,
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure user is authenticated and belongs to the organization
|
||||
if (!authOrgId || !authUserId || authOrgId !== requestedOrgId) {
|
||||
throw new Error('Unauthorized: Invalid organization access');
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ export async function getOrganizationSettings(): Promise<OrganizationSettings> {
|
|||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
// Organization settings are only available for organizations, not personal accounts
|
||||
if (!authResult.orgId) {
|
||||
return ORGANIZATION_DEFAULT;
|
||||
}
|
||||
|
||||
const settings = await db
|
||||
.select()
|
||||
.from(orgSettings)
|
||||
|
|
@ -64,6 +69,13 @@ export async function updateOrganization(data: UpdateOrganizationRequest) {
|
|||
|
||||
const { userId, orgId } = authResult;
|
||||
|
||||
// Organization settings are only available for organizations, not personal accounts
|
||||
if (!orgId) {
|
||||
throw new Error(
|
||||
'Organization settings are only available for organization accounts',
|
||||
);
|
||||
}
|
||||
|
||||
const validatedData = updateOrganizationSchema.parse(data);
|
||||
|
||||
// Perform database update in a transaction
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ export async function canShareTask(taskId: string): Promise<{
|
|||
task?: TaskWithUser;
|
||||
error?: string;
|
||||
userId?: string;
|
||||
orgId?: string;
|
||||
orgRole?: string;
|
||||
orgId?: string | null;
|
||||
orgRole?: string | null;
|
||||
}> {
|
||||
try {
|
||||
// Get authentication info
|
||||
|
|
@ -57,6 +57,24 @@ export async function canShareTask(taskId: string): Promise<{
|
|||
|
||||
const { userId, orgId, orgRole } = authResult;
|
||||
|
||||
// Handle personal context
|
||||
if (!orgId) {
|
||||
// Personal users can only share tasks they created
|
||||
const tasks = await getTasks({ taskId, orgId: null, userId });
|
||||
const task = tasks[0];
|
||||
|
||||
if (!task || task.userId !== userId) {
|
||||
return {
|
||||
canShare: false,
|
||||
error:
|
||||
'Task not found or you do not have permission to share this task',
|
||||
};
|
||||
}
|
||||
|
||||
return { canShare: true, task, userId, orgId: null, orgRole: null };
|
||||
}
|
||||
|
||||
// Organization context - existing logic
|
||||
// Admins can share any task in the organization
|
||||
if (orgRole === 'org:admin') {
|
||||
const tasks = await getTasks({
|
||||
|
|
@ -115,15 +133,6 @@ export async function createTaskShare(data: CreateTaskShareRequest) {
|
|||
visibility = TaskShareVisibility.ORGANIZATION,
|
||||
} = result.data;
|
||||
|
||||
const orgSettingsData = await getOrganizationSettings();
|
||||
|
||||
if (!orgSettingsData.cloudSettings?.enableTaskSharing) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Task sharing is not enabled for this organization',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user can share this specific task (includes auth)
|
||||
const { canShare, task, error, userId, orgId, orgRole } =
|
||||
await canShareTask(taskId);
|
||||
|
|
@ -132,17 +141,39 @@ export async function createTaskShare(data: CreateTaskShareRequest) {
|
|||
return { success: false, error: error || 'Access denied' };
|
||||
}
|
||||
|
||||
if (!task || !userId || !orgId || !orgRole) {
|
||||
if (!task || !userId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Task not found or authentication failed',
|
||||
};
|
||||
}
|
||||
|
||||
const expirationDaysToUse =
|
||||
expirationDays ||
|
||||
orgSettingsData.cloudSettings?.taskShareExpirationDays ||
|
||||
DEFAULT_SHARE_EXPIRATION_DAYS;
|
||||
let expirationDaysToUse: number;
|
||||
let shareVisibility: string;
|
||||
|
||||
if (!orgId) {
|
||||
// Personal account - always public, 30-day expiration
|
||||
expirationDaysToUse = 30; // Fixed 30-day expiration for personal accounts
|
||||
shareVisibility = TaskShareVisibility.PUBLIC; // Personal accounts only create public shares
|
||||
} else {
|
||||
// Organization account
|
||||
const orgSettingsData = await getOrganizationSettings();
|
||||
|
||||
if (!orgSettingsData.cloudSettings?.enableTaskSharing) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Task sharing is not enabled for this organization',
|
||||
};
|
||||
}
|
||||
|
||||
expirationDaysToUse =
|
||||
expirationDays ||
|
||||
orgSettingsData.cloudSettings?.taskShareExpirationDays ||
|
||||
DEFAULT_SHARE_EXPIRATION_DAYS;
|
||||
|
||||
// Organization shares default to organization visibility
|
||||
shareVisibility = visibility;
|
||||
}
|
||||
|
||||
const expiresAt = calculateExpirationDate(expirationDaysToUse);
|
||||
const shareToken = generateShareToken();
|
||||
|
|
@ -152,10 +183,10 @@ export async function createTaskShare(data: CreateTaskShareRequest) {
|
|||
.insert(taskShares)
|
||||
.values({
|
||||
taskId,
|
||||
orgId,
|
||||
orgId, // Will be null for personal accounts
|
||||
createdByUserId: userId,
|
||||
shareToken,
|
||||
visibility,
|
||||
visibility: shareVisibility,
|
||||
expiresAt,
|
||||
})
|
||||
.returning();
|
||||
|
|
@ -164,25 +195,28 @@ export async function createTaskShare(data: CreateTaskShareRequest) {
|
|||
throw new Error('Failed to create task share');
|
||||
}
|
||||
|
||||
await insertAuditLog(tx, {
|
||||
userId,
|
||||
orgId,
|
||||
targetType: AuditLogTargetType.TASK_SHARE,
|
||||
targetId: taskId,
|
||||
newValue: {
|
||||
action: 'created',
|
||||
shareId: insertedShare[0].id,
|
||||
visibility,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
taskOwnerId: task.userId,
|
||||
sharedByAdmin: orgRole === 'org:admin' && task.userId !== userId,
|
||||
},
|
||||
description: `Created ${visibility} task share for task ${taskId}${
|
||||
orgRole === 'org:admin' && task.userId !== userId
|
||||
? ` (admin sharing task created by ${task.user.name})`
|
||||
: ''
|
||||
}`,
|
||||
});
|
||||
// Only create audit log for organization accounts
|
||||
if (orgId) {
|
||||
await insertAuditLog(tx, {
|
||||
userId,
|
||||
orgId,
|
||||
targetType: AuditLogTargetType.TASK_SHARE,
|
||||
targetId: taskId,
|
||||
newValue: {
|
||||
action: 'created',
|
||||
shareId: insertedShare[0].id,
|
||||
visibility: shareVisibility,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
taskOwnerId: task.userId,
|
||||
sharedByAdmin: orgRole === 'org:admin' && task.userId !== userId,
|
||||
},
|
||||
description: `Created ${shareVisibility} task share for task ${taskId}${
|
||||
orgRole === 'org:admin' && task.userId !== userId
|
||||
? ` (admin sharing task created by ${task.user.name})`
|
||||
: ''
|
||||
}`,
|
||||
});
|
||||
}
|
||||
|
||||
return insertedShare;
|
||||
});
|
||||
|
|
@ -251,16 +285,17 @@ export async function getTaskByShareToken(token: string): Promise<{
|
|||
const userId = authResult.success ? authResult.userId : null;
|
||||
const orgId = authResult.success ? authResult.orgId : null;
|
||||
|
||||
// For organization shares, require auth and matching orgId
|
||||
if (!userId || !orgId || orgId !== share.orgId) {
|
||||
throw new Error('Authentication required for organization shares');
|
||||
}
|
||||
}
|
||||
// For public shares, no auth check needed
|
||||
// For public shares (including personal shares), no auth check needed
|
||||
|
||||
// Get task data based on visibility
|
||||
const tasks = await getTasks({
|
||||
taskId: share.taskId,
|
||||
orgId: share.orgId,
|
||||
orgId: share.orgId, // Will be null for personal shares
|
||||
allowCrossUserAccess: true,
|
||||
skipAuth: share.visibility === TaskShareVisibility.PUBLIC, // Skip auth for public shares
|
||||
});
|
||||
|
|
@ -270,7 +305,12 @@ export async function getTaskByShareToken(token: string): Promise<{
|
|||
return null;
|
||||
}
|
||||
|
||||
const messages = await getMessages(share.taskId);
|
||||
const messages = await getMessages(
|
||||
share.taskId,
|
||||
share.orgId,
|
||||
task.userId,
|
||||
share.visibility === TaskShareVisibility.PUBLIC, // Skip auth for public shares
|
||||
);
|
||||
|
||||
return {
|
||||
task,
|
||||
|
|
@ -309,10 +349,25 @@ export async function deleteTaskShare(shareId: string) {
|
|||
}
|
||||
|
||||
// First, find the share to check permissions
|
||||
let whereConditions;
|
||||
if (!orgId) {
|
||||
// For personal context, find shares with null orgId
|
||||
whereConditions = and(
|
||||
eq(taskShares.id, shareId),
|
||||
sql`${taskShares.orgId} IS NULL`,
|
||||
);
|
||||
} else {
|
||||
// For organization context, find shares with matching orgId
|
||||
whereConditions = and(
|
||||
eq(taskShares.id, shareId),
|
||||
eq(taskShares.orgId, orgId),
|
||||
);
|
||||
}
|
||||
|
||||
const [share] = await db
|
||||
.select()
|
||||
.from(taskShares)
|
||||
.where(and(eq(taskShares.id, shareId), eq(taskShares.orgId, orgId)))
|
||||
.where(whereConditions)
|
||||
.limit(1);
|
||||
|
||||
if (!share) {
|
||||
|
|
@ -320,34 +375,47 @@ export async function deleteTaskShare(shareId: string) {
|
|||
}
|
||||
|
||||
// Check if user can delete this share
|
||||
// Admins can delete any share, members can only delete shares they created
|
||||
if (orgRole !== 'org:admin' && share.createdByUserId !== userId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Access denied: You can only delete shares you created',
|
||||
};
|
||||
if (!orgId) {
|
||||
// Personal users can only delete shares they created
|
||||
if (share.createdByUserId !== userId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Access denied: You can only delete shares you created',
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Organization context - admins can delete any share, members can only delete shares they created
|
||||
if (orgRole !== 'org:admin' && share.createdByUserId !== userId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Access denied: You can only delete shares you created',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(taskShares).where(eq(taskShares.id, shareId));
|
||||
|
||||
await insertAuditLog(tx, {
|
||||
userId,
|
||||
orgId,
|
||||
targetType: AuditLogTargetType.TASK_SHARE,
|
||||
targetId: share.taskId,
|
||||
newValue: {
|
||||
action: 'deleted',
|
||||
shareId: share.id,
|
||||
deletedByAdmin:
|
||||
orgRole === 'org:admin' && share.createdByUserId !== userId,
|
||||
},
|
||||
description: `Deleted task share for task ${share.taskId}${
|
||||
orgRole === 'org:admin' && share.createdByUserId !== userId
|
||||
? ' (admin deletion)'
|
||||
: ''
|
||||
}`,
|
||||
});
|
||||
// Only create audit log for organization accounts
|
||||
if (orgId) {
|
||||
await insertAuditLog(tx, {
|
||||
userId,
|
||||
orgId,
|
||||
targetType: AuditLogTargetType.TASK_SHARE,
|
||||
targetId: share.taskId,
|
||||
newValue: {
|
||||
action: 'deleted',
|
||||
shareId: share.id,
|
||||
deletedByAdmin:
|
||||
orgRole === 'org:admin' && share.createdByUserId !== userId,
|
||||
},
|
||||
description: `Deleted task share for task ${share.taskId}${
|
||||
orgRole === 'org:admin' && share.createdByUserId !== userId
|
||||
? ' (admin deletion)'
|
||||
: ''
|
||||
}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, message: 'Task share deleted successfully' };
|
||||
|
|
@ -369,19 +437,35 @@ export async function getTaskShares(taskId: string): Promise<TaskShare[]> {
|
|||
throw new Error(error || 'Task not found or access denied');
|
||||
}
|
||||
|
||||
if (!userId || !orgId) {
|
||||
if (!userId) {
|
||||
throw new Error('Authentication failed');
|
||||
}
|
||||
|
||||
// For admins, show all shares for the task
|
||||
// For members, only show shares they created
|
||||
const whereConditions = [
|
||||
eq(taskShares.taskId, taskId),
|
||||
eq(taskShares.orgId, orgId),
|
||||
];
|
||||
let whereConditions;
|
||||
|
||||
if (orgRole !== 'org:admin') {
|
||||
whereConditions.push(eq(taskShares.createdByUserId, userId));
|
||||
if (!orgId) {
|
||||
// Personal context - only show shares they created for this task
|
||||
whereConditions = [
|
||||
eq(taskShares.taskId, taskId),
|
||||
sql`${taskShares.orgId} IS NULL`,
|
||||
eq(taskShares.createdByUserId, userId),
|
||||
];
|
||||
} else {
|
||||
// Organization context - existing logic
|
||||
if (!orgId) {
|
||||
throw new Error('Organization ID required for organization context');
|
||||
}
|
||||
|
||||
// For admins, show all shares for the task
|
||||
// For members, only show shares they created
|
||||
whereConditions = [
|
||||
eq(taskShares.taskId, taskId),
|
||||
eq(taskShares.orgId, orgId),
|
||||
];
|
||||
|
||||
if (orgRole !== 'org:admin') {
|
||||
whereConditions.push(eq(taskShares.createdByUserId, userId));
|
||||
}
|
||||
}
|
||||
|
||||
const shares = await db
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ export default async function AuthenticatedLayout({
|
|||
// Set enhanced Sentry context for authenticated users.
|
||||
const { userId, orgId, orgRole } = authResult;
|
||||
setSentryUserContext({ id: userId, orgId, orgRole });
|
||||
setSentryOrganizationContext(orgId, orgRole);
|
||||
if (orgId && orgRole) {
|
||||
setSentryOrganizationContext(orgId, orgRole);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuth } from '@clerk/nextjs';
|
||||
|
||||
import type { TaskWithUser } from '@/actions/analytics';
|
||||
import { getMessages } from '@/actions/analytics';
|
||||
|
|
@ -16,9 +17,11 @@ type TaskModalProps = {
|
|||
};
|
||||
|
||||
export const TaskModal = ({ task, open, onClose }: TaskModalProps) => {
|
||||
const { orgId, userId } = useAuth();
|
||||
|
||||
const { data: messages = [] } = useQuery({
|
||||
queryKey: ['messages', task.taskId],
|
||||
queryFn: () => getMessages(task.taskId),
|
||||
queryKey: ['messages', task.taskId, orgId, userId],
|
||||
queryFn: () => getMessages(task.taskId, orgId, userId, false),
|
||||
enabled: open && !!task.taskId,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -25,13 +25,18 @@ export const Tasks = ({
|
|||
const { orgId } = useAuth();
|
||||
|
||||
const { data = [], isPending } = useQuery({
|
||||
queryKey: ['getTasks', orgId, userRole === 'member' ? currentUserId : null],
|
||||
queryKey: [
|
||||
'getTasks',
|
||||
orgId,
|
||||
userRole === 'member' ? currentUserId : null,
|
||||
!orgId,
|
||||
],
|
||||
queryFn: () =>
|
||||
getTasks({
|
||||
orgId,
|
||||
userId: userRole === 'member' ? currentUserId : undefined,
|
||||
}),
|
||||
enabled: !!orgId,
|
||||
enabled: true, // Run for both personal and organization context
|
||||
});
|
||||
|
||||
const tasks = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,9 @@ export default async function Page(props: Props) {
|
|||
redirect(`/sign-in?${authParams.toString()}`);
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
// For personal accounts, orgId will be null - that's okay for extensions
|
||||
// Only redirect to select-org if user is not authenticated or not in personal context
|
||||
if (!orgId && !(authResult.success && !authResult.orgId)) {
|
||||
redirect(`/select-org?${authParams.toString()}`);
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +48,7 @@ export default async function Page(props: Props) {
|
|||
const params = new URLSearchParams({
|
||||
state,
|
||||
code,
|
||||
organizationId: orgId,
|
||||
...(orgId && { organizationId: orgId }), // Only include orgId if it exists
|
||||
});
|
||||
|
||||
editorRedirect = new URL(
|
||||
|
|
|
|||
|
|
@ -57,7 +57,8 @@ export const SelectOrg = () => {
|
|||
<OrganizationList
|
||||
afterSelectOrganizationUrl={redirectUrl}
|
||||
afterCreateOrganizationUrl={redirectUrl}
|
||||
hidePersonal
|
||||
afterSelectPersonalUrl={redirectUrl}
|
||||
hidePersonal={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -37,24 +37,44 @@ describe('/api/events POST', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should return 401 when organization is not provided', async () => {
|
||||
it('should allow personal accounts (orgId is null)', async () => {
|
||||
mockAuth.mockResolvedValue({
|
||||
userId: 'test-user-id',
|
||||
orgId: null,
|
||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const validEvent = {
|
||||
type: 'Task Created',
|
||||
properties: {
|
||||
appName: 'test-app',
|
||||
appVersion: '1.0.0',
|
||||
vscodeVersion: '1.80.0',
|
||||
platform: 'darwin',
|
||||
editorName: 'vscode',
|
||||
language: 'typescript',
|
||||
mode: 'code',
|
||||
taskId: 'task-123',
|
||||
apiProvider: 'anthropic',
|
||||
modelId: 'claude-3-sonnet',
|
||||
},
|
||||
};
|
||||
|
||||
const request = new NextRequest('http://localhost/api/events', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type: 'test-event' }),
|
||||
body: JSON.stringify(validEvent),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(data).toEqual({
|
||||
success: false,
|
||||
error: 'Authentication required',
|
||||
expect(response.status).toBe(200);
|
||||
expect(data).toEqual({ success: true, id: expect.any(String) });
|
||||
expect(mockCaptureEvent).toHaveBeenCalledWith({
|
||||
id: expect.any(String),
|
||||
orgId: null, // Personal account has null orgId
|
||||
userId: 'test-user-id',
|
||||
timestamp: expect.any(Number),
|
||||
event: validEvent,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ export const NavbarHeader = (props: NavbarHeaderProps) => (
|
|||
organizationProfileMode="navigation"
|
||||
organizationProfileUrl="/org"
|
||||
afterCreateOrganizationUrl="/usage"
|
||||
hidePersonal
|
||||
hidePersonal={false}
|
||||
afterSelectPersonalUrl="/usage"
|
||||
/>
|
||||
</div>
|
||||
<ul className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
Check,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth } from '@clerk/nextjs';
|
||||
|
||||
import type { TaskShare } from '@roo-code-cloud/db';
|
||||
|
||||
|
|
@ -52,11 +53,13 @@ export const ShareButton = ({ task }: ShareButtonProps) => {
|
|||
TaskShareVisibility.ORGANIZATION,
|
||||
);
|
||||
|
||||
const { orgId } = useAuth();
|
||||
const { data: orgSettings } = useOrganizationSettings();
|
||||
|
||||
const expirationDays =
|
||||
orgSettings?.cloudSettings?.taskShareExpirationDays ??
|
||||
DEFAULT_SHARE_EXPIRATION_DAYS;
|
||||
const expirationDays = !orgId
|
||||
? 30 // Fixed 30 days for personal accounts
|
||||
: (orgSettings?.cloudSettings?.taskShareExpirationDays ??
|
||||
DEFAULT_SHARE_EXPIRATION_DAYS);
|
||||
|
||||
const loadShares = async () => {
|
||||
try {
|
||||
|
|
@ -73,7 +76,10 @@ export const ShareButton = ({ task }: ShareButtonProps) => {
|
|||
if (open) {
|
||||
loadShares();
|
||||
setNewShareUrl(null);
|
||||
setVisibility(TaskShareVisibility.ORGANIZATION); // Reset to default when opening
|
||||
// Set appropriate default visibility based on context
|
||||
setVisibility(
|
||||
!orgId ? TaskShareVisibility.PUBLIC : TaskShareVisibility.ORGANIZATION,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -144,9 +150,9 @@ export const ShareButton = ({ task }: ShareButtonProps) => {
|
|||
<DialogDescription>
|
||||
{newShareUrl
|
||||
? 'Your share link is ready to use.'
|
||||
: visibility === TaskShareVisibility.ORGANIZATION
|
||||
? 'Create a link to share this task with your team.'
|
||||
: 'Create a public link that anyone can access.'}
|
||||
: visibility === TaskShareVisibility.PUBLIC
|
||||
? 'Create a public link that anyone can access.'
|
||||
: 'Create a link to share this task with your team.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -196,81 +202,83 @@ export const ShareButton = ({ task }: ShareButtonProps) => {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Visibility Selector */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium text-foreground">
|
||||
Who can access this link?
|
||||
</Label>
|
||||
<div className="grid gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setVisibility(TaskShareVisibility.ORGANIZATION)
|
||||
}
|
||||
className={`relative flex items-start gap-3 p-4 rounded-lg border-2 transition-all hover:bg-muted/50 ${
|
||||
visibility === TaskShareVisibility.ORGANIZATION
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 mt-0.5 ${
|
||||
{/* Visibility Selector - only show for organization accounts */}
|
||||
{orgId && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium text-foreground">
|
||||
Who can access this link?
|
||||
</Label>
|
||||
<div className="grid gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setVisibility(TaskShareVisibility.ORGANIZATION)
|
||||
}
|
||||
className={`relative flex items-start gap-3 p-4 rounded-lg border-2 transition-all hover:bg-muted/50 ${
|
||||
visibility === TaskShareVisibility.ORGANIZATION
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground'
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
<Users className="size-5" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">
|
||||
Organization
|
||||
</span>
|
||||
{visibility === TaskShareVisibility.ORGANIZATION && (
|
||||
<Check className="size-4 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`flex-shrink-0 mt-0.5 ${
|
||||
visibility === TaskShareVisibility.ORGANIZATION
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Users className="size-5" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Only members of your organization can view
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">
|
||||
Organization
|
||||
</span>
|
||||
{visibility === TaskShareVisibility.ORGANIZATION && (
|
||||
<Check className="size-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Only members of your organization can view
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisibility(TaskShareVisibility.PUBLIC)}
|
||||
className={`relative flex items-start gap-3 p-4 rounded-lg border-2 transition-all hover:bg-muted/50 ${
|
||||
visibility === TaskShareVisibility.PUBLIC
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 mt-0.5 ${
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisibility(TaskShareVisibility.PUBLIC)}
|
||||
className={`relative flex items-start gap-3 p-4 rounded-lg border-2 transition-all hover:bg-muted/50 ${
|
||||
visibility === TaskShareVisibility.PUBLIC
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground'
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
<Globe className="size-5" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">
|
||||
Public
|
||||
</span>
|
||||
{visibility === TaskShareVisibility.PUBLIC && (
|
||||
<Check className="size-4 text-primary" />
|
||||
)}
|
||||
<div
|
||||
className={`flex-shrink-0 mt-0.5 ${
|
||||
visibility === TaskShareVisibility.PUBLIC
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Globe className="size-5" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Anyone with the link can view
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">
|
||||
Public
|
||||
</span>
|
||||
{visibility === TaskShareVisibility.PUBLIC && (
|
||||
<Check className="size-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Anyone with the link can view
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleCreateShare}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export const UsageCard = ({
|
|||
orgId,
|
||||
selectedPeriod.value,
|
||||
selectedPeriod.granularity,
|
||||
userRole === 'member' ? currentUserId : null,
|
||||
userRole === 'member' || !orgId ? currentUserId : null,
|
||||
],
|
||||
queryFn: () =>
|
||||
getUsage({
|
||||
|
|
@ -60,16 +60,22 @@ export const UsageCard = ({
|
|||
selectedPeriod.granularity === 'daily'
|
||||
? (selectedPeriod.value as 7 | 30 | 90)
|
||||
: (selectedPeriod.value as 1), // Use 1 day for 24h view
|
||||
userId: userRole === 'member' ? currentUserId : undefined,
|
||||
userId: userRole === 'member' || !orgId ? currentUserId : undefined,
|
||||
}),
|
||||
enabled: !!orgId,
|
||||
enabled: !!orgId || (!orgId && !!currentUserId), // Run for org context OR personal context with userId
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="relative">
|
||||
<CardTitle>{t('analytics_title')}</CardTitle>
|
||||
<CardDescription>{t('analytics_description')}</CardDescription>
|
||||
<CardTitle>
|
||||
{!orgId ? 'Personal Account Usage' : t('analytics_title')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{!orgId
|
||||
? 'Your personal account activity and usage'
|
||||
: t('analytics_description')}
|
||||
</CardDescription>
|
||||
{path !== '/usage' && (
|
||||
<EnhancedButton
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -479,15 +479,15 @@ export const UsageChart = ({
|
|||
orgId,
|
||||
timePeriodConfig.value,
|
||||
timePeriodConfig.granularity,
|
||||
userRole === 'member' ? currentUserId : null,
|
||||
userRole === 'member' || !orgId ? currentUserId : null,
|
||||
],
|
||||
queryFn: () =>
|
||||
getHourlyUsageByUser({
|
||||
orgId,
|
||||
timePeriod: timePeriodConfig.value,
|
||||
userId: userRole === 'member' ? currentUserId : undefined,
|
||||
userId: userRole === 'member' || !orgId ? currentUserId : undefined,
|
||||
}),
|
||||
enabled: !!orgId,
|
||||
enabled: !!orgId || (!orgId && !!currentUserId), // Run for org context OR personal context with userId
|
||||
});
|
||||
|
||||
// Process data based on granularity
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
} from '@roo-code/types';
|
||||
|
||||
export const PROVIDERS: Record<
|
||||
Exclude<ProviderName, 'fake-ai' | 'human-relay'>,
|
||||
Exclude<ProviderName, 'fake-ai' | 'human-relay' | 'claude-code'>,
|
||||
{ id: ProviderName; label: string; models: string[] }
|
||||
> = {
|
||||
anthropic: {
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ export type UserAuthSuccess = {
|
|||
success: true;
|
||||
userType: 'user';
|
||||
userId: string;
|
||||
orgId: string;
|
||||
orgRole: OrgRole;
|
||||
orgId: string | null;
|
||||
orgRole: OrgRole | null;
|
||||
};
|
||||
|
||||
export type AgentAuthSuccess = {
|
||||
|
|
|
|||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
|
|
@ -201,8 +201,8 @@ importers:
|
|||
specifier: workspace:^
|
||||
version: link:../../packages/db
|
||||
'@roo-code/types':
|
||||
specifier: ^1.26.0
|
||||
version: 1.26.0
|
||||
specifier: ^1.28.0
|
||||
version: 1.28.0
|
||||
'@sentry/nextjs':
|
||||
specifier: ^9.23.0
|
||||
version: 9.23.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.3(@babel/core@7.27.3)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.99.9)
|
||||
|
|
@ -2288,6 +2288,9 @@ packages:
|
|||
'@roo-code/types@1.26.0':
|
||||
resolution: {integrity: sha512-FX+9jl+PRpZG0n+MUFXjlelDdzEVLQULfHAdBCT7npOs0MrC5IaBGxtuZMYRKWYzEvgwRM96oqXgr6CYkNIWZQ==}
|
||||
|
||||
'@roo-code/types@1.28.0':
|
||||
resolution: {integrity: sha512-kJAWDaY80BLPRxVa1Nz3Fuj7tvnSVMsRlpgNq3YXvFyQwWsui7DZz/AgX5qm+CwcAHXHBPmCUn5oV5XnEAEykw==}
|
||||
|
||||
'@schummar/icu-type-parser@1.21.5':
|
||||
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
|
||||
|
||||
|
|
@ -8323,6 +8326,8 @@ snapshots:
|
|||
|
||||
'@roo-code/types@1.26.0': {}
|
||||
|
||||
'@roo-code/types@1.28.0': {}
|
||||
|
||||
'@schummar/icu-type-parser@1.21.5': {}
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1': {}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue