Monorepo + (disabled) agent authentication mechanism (#106)

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
Chris Estreich 2025-06-19 12:19:50 -07:00 committed by GitHub
parent c59248bda3
commit 008c548c07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
243 changed files with 3842 additions and 8402 deletions

View file

@ -8,7 +8,7 @@ on:
env:
NODE_VERSION: 20.19.2
PNPM_VERSION: 10.8.1
PNPM_VERSION: 10.12.1
jobs:
build:
@ -29,8 +29,8 @@ jobs:
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Build Next.js
run: npx dotenvx run -f .env.test -- pnpm build
- name: Build workspaces
run: pnpm build
test:
runs-on: ubuntu-latest
@ -69,7 +69,7 @@ jobs:
- name: Run type checker
run: pnpm check-types
- name: Setup database schema
run: pnpm db:test:push --force
run: pnpm --filter @roo-code-cloud/web db:test:push --force
env:
DATABASE_URL: postgres://postgres:password@localhost:5432/roo_code_test
- name: Run unit tests
@ -106,5 +106,6 @@ jobs:
run: pnpm install
- name: Migrate production database
run: npx drizzle-kit migrate
working-directory: apps/web
env:
DATABASE_URL: ${{ secrets.PRODUCTION_DATABASE_URL }}

43
.gitignore vendored
View file

@ -1,56 +1,19 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# database
*.db
# testing
/coverage
# storybook
storybook-static
*storybook.log
# playwright
/test-results/
/playwright-report/
/playwright/.cache/
# next.js
/.next
/out
# cache
.swc/
# production
/build
node_modules
# misc
.DS_Store
*.pem
Thumbs.db
# debug
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
# .env
.env*.local
.env*.production
# local folder
local
# turbo
.turbo
# vercel
.vercel
# docker
.docker/*
!.docker/scripts

View file

@ -6,3 +6,4 @@ if [ "$branch" = "main" ]; then
fi
npx lint-staged
pnpm lint

View file

@ -1,21 +0,0 @@
import type { StorybookConfig } from '@storybook/nextjs';
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-onboarding',
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/nextjs',
options: {},
},
staticDirs: ['../public'],
core: {
disableTelemetry: true,
},
};
export default config;

View file

@ -1,19 +0,0 @@
import '../src/app/globals.css';
import type { Preview } from '@storybook/react';
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
nextjs: {
appDirectory: true,
},
},
};
export default preview;

19
.vscode/launch.json vendored
View file

@ -1,19 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug full stack",
"type": "node-terminal",
"request": "launch",
"command": "npm run dev",
"serverReadyAction": {
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}

21
.vscode/tasks.json vendored
View file

@ -1,21 +0,0 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "Project wide type checking with TypeScript",
"type": "npm",
"script": "check-types",
"problemMatcher": ["$tsc"],
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"clear": true,
"reveal": "never"
}
}
]
}

View file

@ -1,61 +1 @@
# Roo Code Cloud
## Run Locally
### Configure Database
Install [Docker Desktop](https://docs.docker.com/desktop/) for your platform.
Once installed, you can pull down a Postgres Docker image and run it:
```sh
docker compose up
```
Postgres will be running locally on port 5432 username `postgres` and password `password`.
### Install Packages
First install [pnpm](https://pnpm.io) using [these instructions](https://pnpm.io/installation). If you're on MacOS the easiest option is to use Homebrew:
```sh
brew install pnpm
```
You can now install the required packages with:
```sh
pnpm install
```
Make sure your database is migrated:
```sh
pnpm db:migrate
```
If everything is working as expected you should be able to run any of the following without errors:
```sh
pnpm lint
pnpm check-types
pnpm test
```
### Start Development Server
Create an `.env.local` file and fill out the required secrets, including:
- `CLICKHOUSE_URL`
- `CLICKHOUSE_PASSWORD`
- `CLERK_SECRET_KEY`
- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`
- `NEXT_PUBLIC_CLERK_FRONTEND_API`
You can now start the Next.js app and [Spotlight](https://spotlightjs.com/) with:
```sh
pnpm dev
```
Your server will be running at [localhost:3000](http://localhost:3000)
# Roo Code Cloud Monorepo

View file

@ -0,0 +1,4 @@
{
"name": "@roo-code-cloud/roomote",
"private": true
}

View file

@ -3,17 +3,17 @@
# for production (https://vercel.com/roo-code/roo-code-cloud/settings/environment-variables).
# Postgres
DATABASE_URL=fake-url
DATABASE_URL=postgres://foo:bar@baz:5432/jabberworky
# ClickHouse
CLICKHOUSE_URL=https://fake-url.com
CLICKHOUSE_URL=https://fake.url
CLICKHOUSE_PASSWORD=fake-password
# Clerk
CLERK_SECRET_KEY=fake-secret-key
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
NEXT_PUBLIC_CLERK_FRONTEND_API=fake-url
NEXT_PUBLIC_CLERK_FRONTEND_API=https://fake.url
# https://clerk.com/docs/deployments/clerk-environment-variables#sign-in-and-sign-up-redirects
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in

7
apps/web/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# next.js
/.next
/out
# docker
.docker/*
!.docker/scripts

61
apps/web/README.md Normal file
View file

@ -0,0 +1,61 @@
# Roo Code Cloud
## Run Locally
### Configure Database
Install [Docker Desktop](https://docs.docker.com/desktop/) for your platform.
Once installed, you can pull down a Postgres Docker image and run it:
```sh
docker compose up
```
Postgres will be running locally on port 5432 username `postgres` and password `password`.
### Install Packages
First install [pnpm](https://pnpm.io) using [these instructions](https://pnpm.io/installation). If you're on MacOS the easiest option is to use Homebrew:
```sh
brew install pnpm
```
You can now install the required packages with:
```sh
pnpm install
```
Make sure your database is migrated:
```sh
pnpm db:migrate
```
If everything is working as expected you should be able to run any of the following without errors:
```sh
pnpm lint
pnpm check-types
pnpm test
```
### Start Development Server
Create an `.env.local` file and fill out the required secrets, including:
- `CLICKHOUSE_URL`
- `CLICKHOUSE_PASSWORD`
- `CLERK_SECRET_KEY`
- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`
- `NEXT_PUBLIC_CLERK_FRONTEND_API`
You can now start the Next.js app and [Spotlight](https://spotlightjs.com/) with:
```sh
pnpm dev
```
Your server will be running at [localhost:3000](http://localhost:3000)

View file

@ -0,0 +1,42 @@
name: roo-code-cloud
services:
postgres:
container_name: roo-code-cloud-postgres
image: postgres:17.5
ports:
- '5432:5432'
volumes:
- ./.docker/data/postgres:/var/lib/postgresql/data
- ./.docker/scripts/postgres:/docker-entrypoint-initdb.d
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
- POSTGRES_DATABASES=roo_code_development,roo_code_test
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres -d roo_code_development']
interval: 5s
timeout: 5s
retries: 5
start_period: 30s
clickhouse:
container_name: roo-code-cloud-clickhouse
image: clickhouse/clickhouse-server
ports:
- '8123:8123'
- '9000:9000'
volumes:
- ./.docker/data/clickhouse:/var/lib/clickhouse/
- ./.docker/logs/clickhouse:/var/log/clickhouse-server/
- ./.docker/scripts/clickhouse:/docker-entrypoint-initdb.d
environment:
- CLICKHOUSE_DB=default
- CLICKHOUSE_USER=default
- CLICKHOUSE_PASSWORD=password
healthcheck:
test: ['CMD-SHELL', 'wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1']
interval: 5s
timeout: 5s
retries: 5
start_period: 30s

View file

@ -28,7 +28,7 @@ export default [
},
},
{
ignores: ['dist/**', '.next', 'storybook-static'],
ignores: ['dist/**', '.next'],
},
{
...pluginReact.configs.flat.recommended,
@ -66,6 +66,7 @@ export default [
'error',
{
caughtErrorsIgnorePattern: '^_',
argsIgnorePattern: '^_',
},
],
},

108
apps/web/package.json Normal file
View file

@ -0,0 +1,108 @@
{
"name": "@roo-code-cloud/web",
"private": true,
"scripts": {
"lint": "eslint .",
"check-types": "tsc --noEmit --pretty",
"test": "dotenvx run -f .env.test -f .env -- vitest run",
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"clean": "rimraf .next out coverage",
"db:generate": "dotenvx run -f .env.development -- drizzle-kit generate",
"db:migrate": "dotenvx run -f .env.development -- drizzle-kit migrate",
"db:push": "dotenvx run -f .env.development -- drizzle-kit push",
"db:test:push": "dotenvx run -f .env.test -- drizzle-kit push",
"db:up": "docker compose up -d --wait && pnpm db:push --force && pnpm db:test:push --force",
"db:down": "docker compose down",
"db:reset": "pnpm db:down && rimraf .docker/data .docker/logs && pnpm db:up"
},
"dependencies": {
"@clerk/localizations": "^3.16.3",
"@clerk/nextjs": "^6.20.2",
"@clerk/themes": "^2.2.48",
"@clickhouse/client": "^1.11.1",
"@hookform/resolvers": "^5.0.1",
"@logtail/pino": "^0.5.5",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-tooltip": "^1.2.7",
"@roo-code/types": "^1.26.0",
"@sentry/nextjs": "^9.23.0",
"@t3-oss/env-nextjs": "^0.13.6",
"@tailwindcss/postcss": "^4.1.8",
"@tanstack/react-query": "^5.79.0",
"@tanstack/react-table": "^8.21.3",
"@types/jsonwebtoken": "^9.0.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.43.1",
"drizzle-zod": "^0.7.1",
"fuzzysort": "^3.1.0",
"import-in-the-middle": "^1.14.2",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.509.0",
"next": "^15.3.3",
"next-intl": "^4.1.0",
"next-themes": "^0.4.6",
"next-typesafe-url": "^5.1.7",
"pino": "^9.7.0",
"pino-pretty": "^13.0.0",
"postgres": "^3.4.7",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.4",
"react-markdown": "^10.1.0",
"react-use": "^17.6.0",
"recharts": "^2.15.3",
"require-in-the-middle": "^7.5.2",
"sonner": "^2.0.3",
"stripe": "^18.2.0",
"tailwind-merge": "^3.3.0",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"zod": "^3.25.41"
},
"devDependencies": {
"@clerk/testing": "^1.7.5",
"@eslint/js": "^9.27.0",
"@next/bundle-analyzer": "^15.3.3",
"@next/eslint-plugin-next": "^15.3.3",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.15.27",
"@types/pg": "^8.15.2",
"@types/react": "^19.1.6",
"@vitejs/plugin-react": "^4.5.0",
"@vitest/coverage-v8": "^3.1.4",
"@vitest/expect": "^3.1.4",
"drizzle-kit": "^0.31.1",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-only-warn": "^1.1.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-turbo": "^2.5.3",
"globals": "^16.2.0",
"jsdom": "^26.1.0",
"postcss": "^8.5.4",
"tailwindcss": "^4.1.8",
"tailwindcss-animate": "^1.0.7",
"typescript-eslint": "^8.33.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.1.4"
}
}

View file

@ -0,0 +1,6 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: ['@tailwindcss/postcss'],
};
export default config;

View file

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

Before

Width:  |  Height:  |  Size: 322 B

After

Width:  |  Height:  |  Size: 322 B

View file

Before

Width:  |  Height:  |  Size: 600 B

After

Width:  |  Height:  |  Size: 600 B

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,526 @@
// pnpm test src/actions/__tests__/agents.test.ts
import { eq } from 'drizzle-orm';
import { clerkClient, auth } from '@clerk/nextjs/server';
import type { Mock } from 'vitest';
import {
client as db,
agents,
users,
orgs,
agentRequestLogs,
} from '@/db/server';
import type { CreateAgentRequest } from '@/types';
import type { Agent } from '@/db/types';
import { createAgent, revokeAgent, updateAgentUsage } from '../agents';
const testUserId = 'user_2abc123def456ghi789';
const testOrgId = 'org_2abc123def456ghi789';
const testClerkAgentId = 'user_2xyz789abc456def123';
vi.mock('crypto', () => ({
default: {
randomBytes: vi.fn(() => ({
toString: vi.fn(() => 'deadbeef'),
})),
},
}));
vi.mock('@clerk/nextjs/server', () => ({
clerkClient: vi.fn(),
auth: vi.fn(),
}));
describe('Agent Actions', () => {
const mockClerkClient = {
users: {
createUser: vi.fn(),
deleteUser: vi.fn(),
},
organizations: {
createOrganizationMembership: vi.fn(),
},
};
beforeEach(async () => {
(clerkClient as unknown as Mock).mockResolvedValue(mockClerkClient);
// Clean up test data
await db.delete(agents).where(eq(agents.orgId, testOrgId));
await db.delete(agents).where(eq(agents.orgId, 'org_2different123456789'));
await db.delete(users).where(eq(users.id, testUserId));
await db.delete(users).where(eq(users.id, 'user_2different123456789'));
await db.delete(orgs).where(eq(orgs.id, testOrgId));
await db.delete(orgs).where(eq(orgs.id, 'org_2different123456789'));
// Create test organization
await db.insert(orgs).values({
id: testOrgId,
name: 'Test Organization',
slug: 'test-org',
imageUrl: 'https://example.com/org-image.jpg',
entity: {},
createdAt: new Date(),
updatedAt: new Date(),
});
// Create test user
await db.insert(users).values({
id: testUserId,
orgId: testOrgId,
orgRole: 'org:admin',
name: 'Test User',
email: 'test@example.com',
imageUrl: 'https://example.com/image.jpg',
entity: {},
createdAt: new Date(),
updatedAt: new Date(),
});
});
describe('createAgent', () => {
const validRequest: CreateAgentRequest = {
displayName: 'Test Agent',
description: 'A test agent',
};
beforeEach(() => {
(auth as unknown as Mock).mockResolvedValue({
userId: testUserId,
orgId: testOrgId,
orgRole: 'org:admin',
});
mockClerkClient.users.createUser.mockResolvedValue({
id: testClerkAgentId,
});
mockClerkClient.organizations.createOrganizationMembership.mockResolvedValue(
{},
);
});
it('should create a new agent successfully', async () => {
const result = await createAgent(validRequest);
expect(result).not.toHaveProperty('error');
expect(result).toHaveProperty('id', testClerkAgentId);
expect(result).toHaveProperty('displayName', 'Test Agent');
expect(result).toHaveProperty('description', 'A test agent');
expect(result).toHaveProperty('orgId', testOrgId);
expect(result).toHaveProperty('createdByUserId', testUserId);
const agents_in_db = await db
.select()
.from(agents)
.where(eq(agents.id, testClerkAgentId));
expect(agents_in_db).toHaveLength(1);
expect(mockClerkClient.users.createUser).toHaveBeenCalledWith({
firstName: 'Test Agent',
lastName: 'Agent',
emailAddress: expect.arrayContaining([
expect.stringContaining('@agents.'),
]),
password: expect.any(String),
skipPasswordChecks: true,
skipPasswordRequirement: true,
});
expect(
mockClerkClient.organizations.createOrganizationMembership,
).toHaveBeenCalledWith({
organizationId: testOrgId,
userId: testClerkAgentId,
role: 'org:agent',
});
});
it('should return error if user is not authenticated', async () => {
(auth as unknown as Mock).mockResolvedValue({
userId: null,
orgId: null,
orgRole: null,
});
const result = await createAgent(validRequest);
expect(result).toHaveProperty('success', false);
expect(result).toHaveProperty('error', 'Unauthorized: User required');
});
it('should return error if user is not org admin', async () => {
(auth as unknown as Mock).mockResolvedValue({
userId: testUserId,
orgId: testOrgId,
orgRole: 'org:member',
});
const result = await createAgent(validRequest);
expect(result).toHaveProperty('success', false);
expect(result).toHaveProperty(
'error',
'Insufficient permissions. Only organization admins can create agents.',
);
});
it('should return error if display name is empty', async () => {
const invalidRequest: CreateAgentRequest = { displayName: '' };
const result = await createAgent(invalidRequest);
expect(result).toHaveProperty('success', false);
expect(result).toHaveProperty('error', 'Display name is required.');
});
it('should return error if display name is too long', async () => {
const invalidRequest: CreateAgentRequest = {
displayName: 'a'.repeat(101),
};
const result = await createAgent(invalidRequest);
expect(result).toHaveProperty('success', false);
expect(result).toHaveProperty(
'error',
'Display name must be 100 characters or less.',
);
});
it('should handle Clerk API errors gracefully', async () => {
mockClerkClient.users.createUser.mockRejectedValue(
new Error('Clerk API error'),
);
const result = await createAgent(validRequest);
expect(result).toHaveProperty('success', false);
expect(result).toHaveProperty(
'error',
'Failed to create agent. Please try again.',
);
});
it('should trim whitespace from display name and description', async () => {
const requestWithWhitespace: CreateAgentRequest = {
displayName: ' Test Agent ',
description: ' A test agent ',
};
const result = await createAgent(requestWithWhitespace);
expect(result).not.toHaveProperty('error');
expect(result).toHaveProperty('displayName', 'Test Agent');
expect(result).toHaveProperty('description', 'A test agent');
});
});
describe('revokeAgent', () => {
let testAgent: Agent;
beforeEach(async () => {
(auth as unknown as Mock).mockResolvedValue({
userId: testUserId,
orgId: testOrgId,
orgRole: 'org:admin',
});
// Create test agent
const [agent] = await db
.insert(agents)
.values({
id: testClerkAgentId,
orgId: testOrgId,
displayName: 'Test Agent',
description: 'Agent to be revoked',
createdByUserId: testUserId,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
testAgent = agent!;
mockClerkClient.users.deleteUser.mockResolvedValue({});
});
it('should revoke an agent successfully', async () => {
const result = await revokeAgent(testClerkAgentId);
expect(result).toHaveProperty('success', true);
const [revokedAgent] = await db
.select()
.from(agents)
.where(eq(agents.id, testClerkAgentId));
expect(revokedAgent).toBeDefined();
expect(revokedAgent!.isActive).toBe(0);
expect(revokedAgent!.updatedAt.getTime()).toBeGreaterThan(
testAgent.updatedAt.getTime(),
);
expect(mockClerkClient.users.deleteUser).toHaveBeenCalledWith(
testClerkAgentId,
);
});
it('should return error if user is not authenticated', async () => {
(auth as unknown as Mock).mockResolvedValue({
userId: null,
orgId: null,
orgRole: null,
});
const result = await revokeAgent(testClerkAgentId);
expect(result).toHaveProperty('success', false);
expect(result).toHaveProperty('error', 'Unauthorized: User required');
});
it('should return error if user is not org admin', async () => {
(auth as unknown as Mock).mockResolvedValue({
userId: testUserId,
orgId: testOrgId,
orgRole: 'org:member',
});
const result = await revokeAgent(testClerkAgentId);
expect(result).toHaveProperty('success', false);
expect(result).toHaveProperty(
'error',
'Insufficient permissions. Only organization admins can revoke agents.',
);
});
it('should return error if agent does not exist', async () => {
const result = await revokeAgent('550e8400-e29b-41d4-a716-446655440099');
expect(result).toHaveProperty('success', false);
expect(result).toHaveProperty(
'error',
'Agent not found or access denied.',
);
});
it('should return error if agent belongs to different org', async () => {
// Create different org first
await db.insert(orgs).values({
id: 'org_2different123456789',
name: 'Different Organization',
slug: 'different-org',
imageUrl: 'https://example.com/different-org.jpg',
entity: {},
createdAt: new Date(),
updatedAt: new Date(),
});
// Create user for different org
await db.insert(users).values({
id: 'user_2different123456789',
orgId: 'org_2different123456789',
orgRole: 'org:admin',
name: 'Different User',
email: 'different@example.com',
imageUrl: 'https://example.com/different-image.jpg',
entity: {},
createdAt: new Date(),
updatedAt: new Date(),
});
// Create agent in different org
const [differentOrgAgent] = await db
.insert(agents)
.values({
id: 'user_2different456def123',
orgId: 'org_2different123456789',
displayName: 'Different Org Agent',
description: 'Agent in different org',
createdByUserId: 'user_2different123456789',
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
expect(differentOrgAgent).toBeDefined();
const result = await revokeAgent(differentOrgAgent!.id);
expect(result).toHaveProperty('success', false);
expect(result).toHaveProperty(
'error',
'Agent not found or access denied.',
);
});
it('should handle Clerk API errors gracefully', async () => {
mockClerkClient.users.deleteUser.mockRejectedValue(
new Error('Clerk API error'),
);
const result = await revokeAgent(testClerkAgentId);
expect(result).toHaveProperty('success', false);
expect(result).toHaveProperty(
'error',
'Failed to revoke agent. Please try again.',
);
});
});
describe('updateAgentUsage', () => {
let testAgent: Agent;
beforeEach(async () => {
const [agent] = await db
.insert(agents)
.values({
id: testClerkAgentId,
orgId: testOrgId,
displayName: 'Test Agent',
description: 'Agent for usage tracking',
totalRequests: 5,
createdByUserId: testUserId,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
testAgent = agent!;
});
it('should update agent usage successfully', async () => {
const endpoint = '/api/orgs/test-org/data';
const method = 'GET';
const statusCode = 200;
const responseTimeMs = 150;
const userAgent = 'Test-Agent/1.0';
const ipAddress = '192.168.1.1';
await updateAgentUsage(
testClerkAgentId,
endpoint,
method,
statusCode,
responseTimeMs,
userAgent,
ipAddress,
);
const [updatedAgent] = await db
.select()
.from(agents)
.where(eq(agents.id, testClerkAgentId));
expect(updatedAgent).toBeDefined();
expect(updatedAgent!.totalRequests).toBe(6); // Increased from 5
expect(updatedAgent!.lastUsedAt).not.toBeNull();
expect(updatedAgent!.updatedAt.getTime()).toBeGreaterThan(
testAgent.updatedAt.getTime(),
);
const requestLogs = await db
.select()
.from(agentRequestLogs)
.where(eq(agentRequestLogs.agentId, testClerkAgentId));
expect(requestLogs).toHaveLength(1);
const log = requestLogs[0];
expect(log).toBeDefined();
expect(log!.endpoint).toBe(endpoint);
expect(log!.method).toBe(method);
expect(log!.statusCode).toBe(statusCode);
expect(log!.responseTimeMs).toBe(responseTimeMs);
expect(log!.userAgent).toBe(userAgent);
expect(log!.ipAddress).toBe(ipAddress);
expect(log!.orgId).toBe(testOrgId);
});
it('should handle missing userAgent and ipAddress', async () => {
await updateAgentUsage(testClerkAgentId, '/api/test', 'POST', 201, 100);
const requestLogs = await db
.select()
.from(agentRequestLogs)
.where(eq(agentRequestLogs.agentId, testClerkAgentId));
expect(requestLogs).toHaveLength(1);
const log = requestLogs[0];
expect(log).toBeDefined();
expect(log!.userAgent).toBeNull();
expect(log!.ipAddress).toBeNull();
});
it('should handle non-existent agent gracefully', async () => {
await updateAgentUsage(
'non-existent-clerk-agent',
'/api/test',
'GET',
404,
50,
);
});
it('should handle database errors gracefully and not throw', async () => {
await expect(
updateAgentUsage(testClerkAgentId, '/api/test', 'GET', 200, 100),
).resolves.not.toThrow();
});
it('should create multiple request logs for multiple calls', async () => {
await updateAgentUsage(
testClerkAgentId,
'/api/endpoint1',
'GET',
200,
100,
);
await updateAgentUsage(
testClerkAgentId,
'/api/endpoint2',
'POST',
201,
150,
);
await updateAgentUsage(
testClerkAgentId,
'/api/endpoint3',
'PUT',
200,
200,
);
// Verify agent total requests updated correctly
const [updatedAgent] = await db
.select()
.from(agents)
.where(eq(agents.id, testClerkAgentId));
expect(updatedAgent).toBeDefined();
expect(updatedAgent!.totalRequests).toBe(8); // 5 initial + 3 new
// Verify all request logs were created
const requestLogs = await db
.select()
.from(agentRequestLogs)
.where(eq(agentRequestLogs.agentId, testClerkAgentId));
expect(requestLogs).toHaveLength(3);
const endpoints = requestLogs.map((log) => log.endpoint).sort();
expect(endpoints).toEqual([
'/api/endpoint1',
'/api/endpoint2',
'/api/endpoint3',
]);
});
});
});

View file

@ -1,4 +1,3 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { TaskWithUser } from '../analytics';
// Create a mock function for the canShareTask function
@ -19,10 +18,6 @@ vi.mock('../analytics', () => ({
}));
describe('Task Sharing Permissions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should allow admin to share any task', async () => {
const mockTask: TaskWithUser = {
taskId: 'task-123',

View file

@ -0,0 +1,187 @@
'use server';
import { eq, and } from 'drizzle-orm';
import { clerkClient } from '@clerk/nextjs/server';
import crypto from 'crypto';
import type { Agent } from '@/db/types';
import {
type CreateAgentRequest,
createAgentRequestSchema,
} from '@/types/agents';
import { client as db, agents, agentRequestLogs } from '@/db/server';
import { authorize } from './auth';
export async function createAgent(
request: CreateAgentRequest,
): Promise<Agent | { success: false; error: string }> {
try {
const authResult = await authorize();
if (!authResult.success) {
return authResult;
}
const { userId, orgId, orgRole } = authResult;
if (orgRole !== 'org:admin') {
return {
success: false,
error:
'Insufficient permissions. Only organization admins can create agents.',
};
}
const validationResult = createAgentRequestSchema.safeParse(request);
if (!validationResult.success) {
const firstError = validationResult.error.errors[0];
return {
success: false,
error: firstError?.message || 'Invalid request data.',
};
}
const { displayName, description } = validationResult.data;
const timestamp = Date.now();
const randomSuffix = crypto.randomBytes(4).toString('hex');
const agentEmail = `agent-${timestamp}-${randomSuffix}@agents.${orgId}.local`;
const client = await clerkClient();
const clerkUser = await client.users.createUser({
firstName: displayName,
lastName: 'Agent',
emailAddress: [agentEmail],
password: crypto.randomBytes(32).toString('hex'),
skipPasswordChecks: true,
skipPasswordRequirement: true,
});
await client.organizations.createOrganizationMembership({
organizationId: orgId,
userId: clerkUser.id,
role: 'org:agent',
});
const agentData = {
id: clerkUser.id,
orgId,
displayName: displayName.trim(),
description: description?.trim() || null,
createdByUserId: userId,
};
const [newAgent] = await db.insert(agents).values(agentData).returning();
if (!newAgent) {
throw new Error('Failed to create agent in database');
}
return newAgent;
} catch {
return {
success: false,
error: 'Failed to create agent. Please try again.',
};
}
}
export async function revokeAgent(
agentId: string,
): Promise<{ success: true } | { success: false; error: string }> {
try {
const authResult = await authorize();
if (!authResult.success) {
return authResult;
}
const { orgId, orgRole } = authResult;
if (orgRole !== 'org:admin') {
return {
success: false,
error:
'Insufficient permissions. Only organization admins can revoke agents.',
};
}
const [agent] = await db
.select()
.from(agents)
.where(and(eq(agents.id, agentId), eq(agents.orgId, orgId)))
.limit(1);
if (!agent) {
return {
success: false,
error: 'Agent not found or access denied.',
};
}
const client = await clerkClient();
await client.users.deleteUser(agent.id);
await db
.update(agents)
.set({
isActive: 0,
updatedAt: new Date(),
})
.where(eq(agents.id, agentId));
return { success: true };
} catch {
return {
success: false,
error: 'Failed to revoke agent. Please try again.',
};
}
}
export async function updateAgentUsage(
agentId: string,
endpoint: string,
method: string,
statusCode: number,
responseTimeMs: number,
userAgent?: string,
ipAddress?: string,
): Promise<void> {
try {
const [agent] = await db
.select()
.from(agents)
.where(eq(agents.id, agentId))
.limit(1);
if (!agent) {
return;
}
await db
.update(agents)
.set({
lastUsedAt: new Date(),
totalRequests: agent.totalRequests + 1,
updatedAt: new Date(),
})
.where(eq(agents.id, agentId));
await db.insert(agentRequestLogs).values({
agentId: agent.id,
orgId: agent.orgId,
endpoint,
method,
statusCode,
responseTimeMs,
userAgent,
ipAddress,
});
} catch {
// NO-OP
}
}

View file

@ -11,7 +11,7 @@ import type { AnyTimePeriod } from '@/types';
import { analytics } from '@/lib/server';
import { tokenSumSql } from '@/lib';
import { type User, getUsersById } from '@/db/server';
import { validateAnalyticsAccess } from '@/actions/auth';
import { authorizeAnalytics } from '@/actions/auth';
type Table = 'events' | 'messages';
@ -85,7 +85,7 @@ export const getUsage = async ({
timePeriod?: AnyTimePeriod;
userId?: string | null;
}): Promise<UsageRecord> => {
const { effectiveUserId } = await validateAnalyticsAccess({
const { effectiveUserId } = await authorizeAnalytics({
requestedOrgId: orgId,
requestedUserId: userId,
});
@ -157,7 +157,7 @@ export const getDeveloperUsage = async ({
timePeriod?: AnyTimePeriod;
userId?: string | null;
}): Promise<DeveloperUsage[]> => {
await validateAnalyticsAccess({
await authorizeAnalytics({
requestedOrgId: orgId,
requestedUserId: userId,
requireAdmin: true,
@ -235,7 +235,7 @@ export const getModelUsage = async ({
timePeriod?: AnyTimePeriod;
userId?: string | null;
}): Promise<ModelUsage[]> => {
await validateAnalyticsAccess({
await authorizeAnalytics({
requestedOrgId: orgId,
requestedUserId: userId,
requireAdmin: true,
@ -315,7 +315,7 @@ export const getTasks = async ({
taskId?: string | null;
allowCrossUserAccess?: boolean;
}): Promise<TaskWithUser[]> => {
const { effectiveUserId } = await validateAnalyticsAccess({
const { effectiveUserId } = await authorizeAnalytics({
requestedOrgId: orgId,
requestedUserId: userId,
allowCrossUserAccess,
@ -424,7 +424,7 @@ export const getHourlyUsageByUser = async ({
timePeriod?: AnyTimePeriod;
userId?: string | null;
}): Promise<HourlyUsageByUser[]> => {
const { effectiveUserId } = await validateAnalyticsAccess({
const { effectiveUserId } = await authorizeAnalytics({
requestedOrgId: orgId,
requestedUserId: userId,
});

View file

@ -1,13 +1,17 @@
'use server';
import { NextRequest } from 'next/server';
import { auth } from '@clerk/nextjs/server';
import type { ApiResponse } from '@/types';
import { type AuthResult, type ApiAuthResult, isOrgRole } from '@/types';
import { Env, logger } from '@/lib/server';
// import {
// type AgentTokenPayload,
// validateAgentToken,
// } from '@/lib/server/agent-auth';
// import { updateAgentUsage } from '@/actions/agents';
export async function validateAuth(): Promise<
{ userId: string; orgId: string; orgRole: string } | ApiResponse
> {
export async function authorize(): Promise<AuthResult> {
const { userId, orgId, orgRole } = await auth();
if (!userId) {
@ -18,13 +22,83 @@ export async function validateAuth(): Promise<
return { success: false, error: 'Unauthorized: Organization required' };
}
return { userId, orgId, orgRole: orgRole || 'unknown' };
return {
success: true,
userType: 'user',
userId,
orgId,
orgRole: isOrgRole(orgRole) ? orgRole : 'org:member',
};
}
/**
* Validates authentication and authorization for API endpoints.
*/
export async function authorizeApi(
_request: NextRequest,
): Promise<ApiAuthResult> {
return authorize();
// const isAgent = request.headers.get('authorization')?.startsWith('Bearer ');
// if (!isAgent) {
// return authorize();
// }
// const startTime = Date.now();
// try {
// const authHeader = request.headers.get('authorization');
// if (!authHeader?.startsWith('Bearer ')) {
// return {
// success: false,
// error: 'Unauthorized: Missing authorization header',
// };
// }
// const token = authHeader.slice(7);
// if (!token) {
// return {
// success: false,
// error: 'Unauthorized: Malformed authorization header',
// };
// }
// let payload: AgentTokenPayload;
// try {
// payload = await validateAgentToken(token);
// } catch {
// return { success: false, error: 'Unauthorized: Invalid token' };
// }
// const { agent_id: userId, org_id: orgId } = payload;
// updateAgentUsage(
// userId,
// new URL(request.url).pathname,
// request.method,
// 200,
// Date.now() - startTime,
// request.headers.get('user-agent') || undefined,
// );
// return { success: true, userType: 'agent', userId, orgId };
// } catch (error) {
// console.error(
// `authorizeApi: ${error instanceof Error ? error.message : 'Unknown error'}`,
// );
// return { success: false, error: 'Unauthorized: Unexpected error' };
// }
}
/**
* Validates authentication and authorization for analytics functions.
*/
export async function validateAnalyticsAccess({
export async function authorizeAnalytics({
requestedOrgId,
requestedUserId,
requireAdmin = false,
@ -34,12 +108,7 @@ export async function validateAnalyticsAccess({
requestedUserId?: string | null;
requireAdmin?: boolean;
allowCrossUserAccess?: boolean;
}): Promise<{
authOrgId: string;
authUserId: string;
orgRole: string;
effectiveUserId: string | null;
}> {
}) {
const { orgId: authOrgId, orgRole, userId: authUserId } = await auth();
// Ensure user is authenticated and belongs to the organization
@ -71,7 +140,7 @@ export async function validateAnalyticsAccess({
return {
authOrgId,
authUserId,
orgRole: orgRole || 'unknown',
orgRole: isOrgRole(orgRole) ? orgRole : 'org:member',
effectiveUserId,
};
}

View file

@ -0,0 +1,155 @@
'use server';
import { eq, sql } from 'drizzle-orm';
import { z } from 'zod';
import {
type OrganizationSettings,
organizationAllowListSchema,
organizationDefaultSettingsSchema,
organizationCloudSettingsSchema,
ORGANIZATION_ALLOW_ALL,
ORGANIZATION_DEFAULT,
} from '@roo-code/types';
import { AuditLogTargetType, client as db, orgSettings } from '@/db/server';
import { authorize } from './auth';
import { insertAuditLog } from './auditLogs';
export async function getOrganizationSettings(): Promise<OrganizationSettings> {
const authResult = await authorize();
if (!authResult.success) {
throw new Error('Unauthorized');
}
const settings = await db
.select()
.from(orgSettings)
.where(eq(orgSettings.orgId, authResult.orgId))
.limit(1);
return settings[0] || ORGANIZATION_DEFAULT;
}
/**
* Schema for updating organization settings
*/
const updateOrganizationSchema = z
.object({
defaultSettings: organizationDefaultSettingsSchema.optional(),
allowList: organizationAllowListSchema.optional(),
cloudSettings: organizationCloudSettingsSchema.optional(),
})
.refine(
(data) =>
data.defaultSettings !== undefined ||
data.allowList !== undefined ||
data.cloudSettings !== undefined,
{
message:
'At least one of defaultSettings, allowList, or cloudSettings must be provided',
},
);
type UpdateOrganizationRequest = z.infer<typeof updateOrganizationSchema>;
export async function updateOrganization(data: UpdateOrganizationRequest) {
const authResult = await authorize();
if (!authResult.success) {
throw new Error('Unauthorized');
}
const { userId, orgId } = authResult;
const validatedData = updateOrganizationSchema.parse(data);
// Perform database update in a transaction
const result = await db.transaction(async (tx) => {
// Get current settings or prepare for insert
const currentSettings = await tx
.select()
.from(orgSettings)
.where(eq(orgSettings.orgId, orgId))
.limit(1);
const isNewRecord = currentSettings.length === 0;
const updateData: Partial<typeof orgSettings.$inferInsert> = {};
if (validatedData.defaultSettings) {
updateData.defaultSettings = validatedData.defaultSettings;
}
if (validatedData.allowList) {
updateData.allowList = validatedData.allowList;
}
if (validatedData.cloudSettings) {
updateData.cloudSettings = validatedData.cloudSettings;
}
let result;
if (isNewRecord) {
result = await tx
.insert(orgSettings)
.values({
orgId,
version: 1,
defaultSettings: validatedData.defaultSettings || {},
allowList: validatedData.allowList || ORGANIZATION_ALLOW_ALL,
cloudSettings: validatedData.cloudSettings || {},
})
.returning();
} else {
result = await tx
.update(orgSettings)
.set({
...updateData,
version: sql`${orgSettings.version} + 1`,
updatedAt: new Date(),
})
.where(eq(orgSettings.orgId, orgId))
.returning();
}
if (validatedData.defaultSettings) {
await insertAuditLog(tx, {
userId,
orgId,
targetType: AuditLogTargetType.DEFAULT_PARAMETERS,
targetId: 'organization-default-settings',
newValue: validatedData.defaultSettings,
description: 'Updated organization default settings',
});
}
if (validatedData.allowList) {
await insertAuditLog(tx, {
userId,
orgId,
targetType: AuditLogTargetType.PROVIDER_WHITELIST,
targetId: 'organization-allow-list',
newValue: validatedData.allowList,
description: 'Updated organization allow list',
});
}
if (validatedData.cloudSettings) {
await insertAuditLog(tx, {
userId,
orgId,
targetType: AuditLogTargetType.CLOUD_SETTINGS,
targetId: 'organization-cloud-settings',
newValue: validatedData.cloudSettings,
description: 'Updated organization cloud settings',
});
}
return result;
});
return result;
}

View file

@ -1,18 +1,16 @@
'use server';
import { eq, and, sql, desc } from 'drizzle-orm';
import { auth } from '@clerk/nextjs/server';
import {
type ApiResponse,
type CreateTaskShareRequest,
createTaskShareSchema,
shareIdSchema,
} from '@/types';
import type { SharedByUser } from '@/types/task-sharing';
import type { SharedByUser } from '@/types';
import { type TaskShare, AuditLogTargetType } from '@/db';
import { client as db, taskShares, users } from '@/db/server';
import { handleError, isAuthSuccess, generateShareToken } from '@/lib/server';
import { handleError, generateShareToken } from '@/lib/server';
import {
isValidShareToken,
isShareExpired,
@ -27,18 +25,10 @@ import {
getMessages,
} from '@/actions/analytics';
import { validateAuth } from './auth';
import { authorize } from './auth';
import { insertAuditLog } from './auditLogs';
import { getOrganizationSettings } from './organizationSettings';
type TaskShareResponse = ApiResponse & {
data?: {
shareUrl: string;
shareId: string;
expiresAt: Date | null;
};
};
/**
* Check if the current user can share a specific task (for UI components)
*/
@ -51,13 +41,14 @@ export async function canShareTask(taskId: string): Promise<{
orgRole?: string;
}> {
try {
// Get authentication info
const { userId, orgId, orgRole } = await auth();
const authResult = await authorize();
if (!userId || !orgId) {
if (!authResult.success) {
return { canShare: false, error: 'Authentication required' };
}
const { userId, orgId, orgRole } = authResult;
// Admins can share any task in the organization
if (orgRole === 'org:admin') {
const tasks = await getTasks({
@ -101,9 +92,7 @@ export async function canShareTask(taskId: string): Promise<{
}
}
export async function createTaskShare(
data: CreateTaskShareRequest,
): Promise<TaskShareResponse> {
export async function createTaskShare(data: CreateTaskShareRequest) {
try {
const result = createTaskShareSchema.safeParse(data);
@ -193,8 +182,8 @@ export async function createTaskShare(
return {
success: true,
data: { shareUrl, shareId: newShare.id, expiresAt },
message: 'Task share created successfully',
data: { shareUrl, shareId: newShare.id, expiresAt },
};
} catch (error) {
return handleError(error, 'task_sharing');
@ -211,9 +200,9 @@ export async function getTaskByShareToken(token: string): Promise<{
sharedAt: Date;
} | null> {
try {
const { userId, orgId } = await auth();
const authResult = await authorize();
if (!userId || !orgId) {
if (!authResult.success) {
throw new Error('Authentication required');
}
@ -232,7 +221,12 @@ export async function getTaskByShareToken(token: string): Promise<{
})
.from(taskShares)
.innerJoin(users, eq(taskShares.createdByUserId, users.id))
.where(and(eq(taskShares.shareToken, token), eq(taskShares.orgId, orgId)))
.where(
and(
eq(taskShares.shareToken, token),
eq(taskShares.orgId, authResult.orgId),
),
)
.limit(1);
if (!shareWithUser) {
@ -250,6 +244,7 @@ export async function getTaskByShareToken(token: string): Promise<{
orgId: share.orgId,
allowCrossUserAccess: true,
});
const task = tasks[0];
if (!task) {
@ -277,16 +272,15 @@ export async function getTaskByShareToken(token: string): Promise<{
/**
* Delete/revoke a task share.
*/
export async function deleteTaskShare(shareId: string): Promise<ApiResponse> {
export async function deleteTaskShare(shareId: string) {
try {
const authResult = await validateAuth();
const authResult = await authorize();
if (!isAuthSuccess(authResult)) {
if (!authResult.success) {
return authResult;
}
const { userId, orgId, orgRole } = authResult;
const shareIdResult = shareIdSchema.safeParse(shareId);
if (!shareIdResult.success) {

View file

@ -0,0 +1,16 @@
import { redirect } from 'next/navigation';
import { authorize } from '@/actions/auth';
import { AuditLogs } from './AuditLogs';
export default async function Page() {
const authResult = await authorize();
// Only admins can access audit logs.
if (!authResult.success || authResult.orgRole !== 'org:admin') {
redirect('/usage');
}
return <AuditLogs />;
}

View file

@ -1,10 +1,12 @@
import { redirect } from 'next/navigation';
import { auth } from '@clerk/nextjs/server';
import {
setSentryUserContext,
setSentryOrganizationContext,
} from '@/lib/server/sentry-context';
import { authorize } from '@/actions/auth';
import { NavbarHeader, NavbarMenu, Section } from '@/components/layout';
export default async function AuthenticatedLayout({
@ -12,24 +14,16 @@ export default async function AuthenticatedLayout({
}: {
children: React.ReactNode;
}) {
const { orgId, orgRole, userId } = await auth();
const authResult = await authorize();
if (!orgId) {
if (!authResult.success) {
redirect('/select-org');
}
// Set enhanced Sentry context for authenticated users
if (userId) {
setSentryUserContext({
id: userId,
orgId,
orgRole,
});
if (orgId) {
setSentryOrganizationContext(orgId, orgRole);
}
}
// Set enhanced Sentry context for authenticated users.
const { userId, orgId, orgRole } = authResult;
setSentryUserContext({ id: userId, orgId, orgRole });
setSentryOrganizationContext(orgId, orgRole);
return (
<>

View file

@ -53,12 +53,7 @@ export const ProviderForm = () => {
),
};
const result = await updateOrganization({ allowList });
if (!result.success) {
throw new Error(result.error || 'Failed to update settings.');
}
await updateOrganization({ allowList });
reset(allowList);
queryClient.invalidateQueries({

View file

@ -1,12 +1,14 @@
import { redirect } from 'next/navigation';
import { auth } from '@clerk/nextjs/server';
import { authorize } from '@/actions/auth';
import { ProviderSettings } from './ProviderSettings';
export default async function Page() {
const { orgRole } = await auth();
const authResult = await authorize();
// Only admins can access provider settings
if (orgRole !== 'org:admin') {
// Only admins can access provider settings.
if (!authResult.success || authResult.orgRole !== 'org:admin') {
redirect('/usage');
}

View file

@ -62,16 +62,13 @@ export const SettingsForm = ({ orgSettings }: SettingsFormProps) => {
taskShareExpirationDays: data.taskShareExpirationDays,
};
const result = await updateOrganization({ cloudSettings });
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.');
}
queryClient.invalidateQueries({
queryKey: [QueryKey.GetOrganizationSettings],
});
toast.success('Settings saved successfully');
} catch (error) {
console.error('Failed to update settings:', error);
toast.error('Failed to save settings. Please try again.');

View file

@ -0,0 +1,16 @@
import { redirect } from 'next/navigation';
import { authorize } from '@/actions/auth';
import { SettingsPage } from './SettingsPage';
export default async function Page() {
const authResult = await authorize();
// Only admins can access settings.
if (!authResult.success || authResult.orgRole !== 'org:admin') {
redirect('/usage');
}
return <SettingsPage />;
}

View file

@ -1,20 +1,16 @@
import { notFound, redirect } from 'next/navigation';
import { auth } from '@clerk/nextjs/server';
import { authorize } from '@/actions/auth';
import { getTaskByShareToken } from '@/actions/taskSharing';
import { SharedTaskView } from '@/components/task-sharing/SharedTaskView';
type SharedTaskPageProps = {
params: {
token: string;
};
};
type SharedTaskPageProps = { params: { token: string } };
export default async function SharedTaskPage({ params }: SharedTaskPageProps) {
const { orgId } = await auth();
const authResult = await authorize();
// Redirect to organization selection if no organization
if (!orgId) {
if (!authResult.success) {
redirect('/select-org');
}
@ -44,7 +40,6 @@ export default async function SharedTaskPage({ params }: SharedTaskPageProps) {
</div>
);
} catch (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">

View file

@ -0,0 +1,16 @@
import { authorize } from '@/actions/auth';
import { Usage } from './Usage';
export default async function Page() {
const authResult = await authorize();
const orgRole = authResult.success ? authResult.orgRole : null;
const userId = authResult.success ? authResult.userId : null;
return (
<Usage
userRole={orgRole === 'org:admin' ? 'admin' : 'member'}
currentUserId={userId}
/>
);
}

View file

@ -1,9 +1,8 @@
import { redirect } from 'next/navigation';
import { auth } from '@clerk/nextjs/server';
import { AuthStateParam } from '@/types';
import { getSignInToken } from '@/actions/auth';
import { EXTENSION_EDITOR, EXTENSION_URI_SCHEME } from '@/lib/constants';
import { authorize, getSignInToken } from '@/actions/auth';
import { DeepLink } from './DeepLink';
@ -24,7 +23,9 @@ export default async function Page(props: Props) {
[AuthStateParam.AuthRedirect]: authRedirect,
});
const { userId, orgId } = await auth();
const authResult = await authorize();
const userId = authResult.success ? authResult.userId : null;
const orgId = authResult.success ? authResult.orgId : null;
const code = userId
? await getSignInToken(userId).catch(() => undefined)

View file

@ -31,7 +31,10 @@ describe('/api/events POST', () => {
const data = await response.json();
expect(response.status).toBe(401);
expect(data).toEqual({ error: 'Unauthorized: User required' });
expect(data).toEqual({
success: false,
error: 'Authentication required',
});
});
it('should return 401 when organization is not provided', async () => {
@ -49,7 +52,10 @@ describe('/api/events POST', () => {
const data = await response.json();
expect(response.status).toBe(401);
expect(data).toEqual({ error: 'Unauthorized: Organization required' });
expect(data).toEqual({
success: false,
error: 'Authentication required',
});
});
});

View file

@ -1,4 +1,3 @@
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { v4 as uuidv4 } from 'uuid';
@ -7,24 +6,20 @@ import {
rooCodeTelemetryEventSchema,
} from '@roo-code/types';
import { authorizeApi } from '@/actions/auth';
import { captureEvent } from '@/actions/analytics';
export async function POST(request: NextRequest) {
const { userId, orgId } = await auth();
const authResult = await authorizeApi(request);
if (!userId) {
if (!authResult.success) {
return NextResponse.json(
{ error: 'Unauthorized: User required' },
{ success: false, error: 'Authentication required' },
{ status: 401 },
);
}
if (!orgId) {
return NextResponse.json(
{ error: 'Unauthorized: Organization required' },
{ status: 401 },
);
}
const { userId, orgId } = authResult;
const id = uuidv4();
const timestamp = Math.round(Date.now() / 1000);

View file

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auth } from '@clerk/nextjs/server';
import { authorizeApi } from '@/actions/auth';
import { createTaskShare } from '@/actions/taskSharing';
import { getTasks } from '@/actions/analytics';
import { getOrganizationSettings } from '@/actions/organizationSettings';
@ -12,16 +12,17 @@ const createShareRequestSchema = z.object({
export async function POST(request: NextRequest) {
try {
// Use existing Clerk authentication
const { userId, orgId } = await auth();
const authResult = await authorizeApi(request);
if (!userId || !orgId) {
if (!authResult.success) {
return NextResponse.json(
{ success: false, error: 'Authentication required' },
{ status: 401 },
);
}
const { userId, orgId } = authResult;
const body = await request.json();
const result = createShareRequestSchema.safeParse(body);
@ -76,6 +77,7 @@ export async function POST(request: NextRequest) {
});
} catch (error) {
console.error('Error in extension share endpoint:', error);
return NextResponse.json(
{
success: false,

View file

@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { authorizeApi } from '@/actions/auth';
export async function GET(request: NextRequest) {
const authResult = await authorizeApi(request);
if (!authResult.success) {
return NextResponse.json(
{ error: 'Unauthorized request' },
{ status: 401 },
);
}
return NextResponse.json(authResult);
}

View file

@ -1,31 +1,25 @@
import { NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { authorizeApi } from '@/actions/auth';
import { getOrganizationSettings } from '@/actions/organizationSettings';
export async function GET() {
export async function GET(request: NextRequest) {
try {
const { userId, orgId } = await auth();
const authResult = await authorizeApi(request);
if (!userId) {
if (!authResult.success) {
return NextResponse.json(
{ error: 'Unauthorized request' },
{ status: 401 },
);
}
if (!orgId) {
return NextResponse.json(
{ error: 'Organization not found' },
{ status: 404 },
);
}
const settings = await getOrganizationSettings();
return NextResponse.json(settings);
} catch (error) {
console.error('Error fetching organization settings:', error);
return NextResponse.json(
{ error: 'Failed to fetch organization settings' },
{ status: 500 },

View file

@ -2,7 +2,6 @@ import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getTranslations, setRequestLocale } from 'next-intl/server';
import { auth } from '@clerk/nextjs/server';
import { getClerkLocale } from '@/i18n/locale';
import { syncAuth } from '@/actions/sync';
@ -15,6 +14,7 @@ import {
} from '@/components/layout';
import './globals.css';
import { authorize } from '@/actions/auth';
export async function generateMetadata(): Promise<Metadata> {
const locale = await getLocale();
@ -53,16 +53,14 @@ export default async function RootLayout({
const locale = await getLocale();
setRequestLocale(locale);
const authData = await auth();
await syncAuth(authData);
const authResult = await authorize();
// Set Sentry user context for server-side error tracking
if (authData.userId) {
setSentryUserContext({
id: authData.userId,
orgId: authData.orgId,
orgRole: authData.orgRole,
});
if (authResult.success) {
await syncAuth(authResult);
// Set Sentry user context for server-side error tracking.
const { userId, orgId, orgRole } = authResult;
setSentryUserContext({ id: userId, orgId, orgRole });
}
return (

View file

@ -136,7 +136,6 @@ const expectSaveButtonVisible = () => {
describe('ProviderForm', () => {
beforeEach(() => {
mockUpdateOrganization.mockResolvedValue({ success: true });
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
@ -335,10 +334,7 @@ describe('ProviderForm', () => {
it('should handle save error gracefully', async () => {
await testErrorHandling(() => {
mockUpdateOrganization.mockResolvedValue({
success: false,
error: 'Network error',
});
mockUpdateOrganization.mockRejectedValue(new Error('Network error'));
});
});

Some files were not shown because too many files have changed in this diff Show more