Monorepo + (disabled) agent authentication mechanism (#106)
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
9
.github/workflows/CI.yml
vendored
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ if [ "$branch" = "main" ]; then
|
|||
fi
|
||||
|
||||
npx lint-staged
|
||||
pnpm lint
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
62
README.md
|
|
@ -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
|
||||
|
|
|
|||
4
apps/roomote/package.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"name": "@roo-code-cloud/roomote",
|
||||
"private": true
|
||||
}
|
||||
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
# next.js
|
||||
/.next
|
||||
/out
|
||||
|
||||
# docker
|
||||
.docker/*
|
||||
!.docker/scripts
|
||||
61
apps/web/README.md
Normal 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)
|
||||
42
apps/web/docker-compose.yml
Normal 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
|
||||
|
|
@ -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: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
0
next-env.d.ts → apps/web/next-env.d.ts
vendored
108
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
apps/web/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: ['@tailwindcss/postcss'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 322 B After Width: | Height: | Size: 322 B |
|
Before Width: | Height: | Size: 600 B After Width: | Height: | Size: 600 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
526
apps/web/src/actions/__tests__/agents.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
187
apps/web/src/actions/agents.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
155
apps/web/src/actions/organizationSettings.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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) {
|
||||
16
apps/web/src/app/(authenticated)/audit-logs/page.tsx
Normal 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 />;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
@ -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({
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
@ -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.');
|
||||
16
apps/web/src/app/(authenticated)/settings/page.tsx
Normal 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 />;
|
||||
}
|
||||
|
|
@ -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">
|
||||
16
apps/web/src/app/(authenticated)/usage/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
16
apps/web/src/app/api/me/route.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
@ -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 (
|
||||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||