Personal accounts (#118)

This commit is contained in:
Matt Rubens 2025-06-23 14:46:10 -04:00 committed by GitHub
parent 6f3db07df5
commit 9e8588a3a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 440 additions and 193 deletions

4
.gitignore vendored
View file

@ -20,5 +20,5 @@ Thumbs.db
.vercel
# docker
.docker/data
.docker/logs
**/.docker/data
**/.docker/logs

View file

@ -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",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(() => {

View file

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

View file

@ -57,7 +57,8 @@ export const SelectOrg = () => {
<OrganizationList
afterSelectOrganizationUrl={redirectUrl}
afterCreateOrganizationUrl={redirectUrl}
hidePersonal
afterSelectPersonalUrl={redirectUrl}
hidePersonal={false}
/>
);
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: {

View file

@ -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
View file

@ -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': {}