mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 269e27e734 |
@@ -1,202 +0,0 @@
|
||||
---
|
||||
name: cross-repo-testing
|
||||
description: This skill should be used when the user asks to "test a cross-repo feature", "deploy a feature branch to staging", "test SDK against OH Cloud", "e2e test a cloud workspace feature", "test provider tokens", "test secrets inheritance", or when changes span the SDK and OpenHands server repos and need end-to-end validation against a staging deployment.
|
||||
triggers:
|
||||
- cross-repo
|
||||
- staging deployment
|
||||
- feature branch deploy
|
||||
- test against cloud
|
||||
- e2e cloud
|
||||
---
|
||||
|
||||
# Cross-Repo Testing: SDK ↔ OpenHands Cloud
|
||||
|
||||
How to end-to-end test features that span `OpenHands/software-agent-sdk` and `OpenHands/OpenHands` (the Cloud backend).
|
||||
|
||||
## Repository Map
|
||||
|
||||
| Repo | Role | What lives here |
|
||||
|------|------|-----------------|
|
||||
| [`software-agent-sdk`](https://github.com/OpenHands/software-agent-sdk) | Agent core | `openhands-sdk`, `openhands-workspace`, `openhands-tools` packages. `OpenHandsCloudWorkspace` lives here. |
|
||||
| [`OpenHands`](https://github.com/OpenHands/OpenHands) | Cloud backend | FastAPI server (`openhands/app_server/`), sandbox management, auth, enterprise integrations. Deployed as OH Cloud. |
|
||||
| [`deploy`](https://github.com/OpenHands/deploy) | Infrastructure | Helm charts + GitHub Actions that build the enterprise Docker image and deploy to staging/production. |
|
||||
|
||||
**Data flow:** SDK client → OH Cloud API (`/api/v1/...`) → sandbox agent-server (inside runtime container)
|
||||
|
||||
## When You Need This
|
||||
|
||||
There are **two flows** depending on which direction the dependency goes:
|
||||
|
||||
| Flow | When | Example |
|
||||
|------|------|---------|
|
||||
| **A — SDK client → new Cloud API** | The SDK calls an API that doesn't exist yet on production | `workspace.get_llm()` calling `GET /api/v1/users/me?expose_secrets=true` |
|
||||
| **B — OH server → new SDK code** | The Cloud server needs unreleased SDK packages or a new agent-server image | Server consumes a new tool, agent behavior, or workspace method from the SDK |
|
||||
|
||||
Flow A only requires deploying the server PR. Flow B requires pinning the SDK to an unreleased commit in the server PR **and** using the SDK PR's agent-server image. Both flows may apply simultaneously.
|
||||
|
||||
---
|
||||
|
||||
## Flow A: SDK Client Tests Against New Cloud API
|
||||
|
||||
Use this when the SDK calls an endpoint that only exists on the server PR branch.
|
||||
|
||||
### A1. Write and test the server-side changes
|
||||
|
||||
In the `OpenHands` repo, implement the new API endpoint(s). Run unit tests:
|
||||
|
||||
```bash
|
||||
cd OpenHands
|
||||
poetry run pytest tests/unit/app_server/test_<relevant>.py -v
|
||||
```
|
||||
|
||||
Push a PR. Wait for the **"Push Enterprise Image" (Docker) CI job** to succeed — this builds `ghcr.io/openhands/enterprise-server:sha-<COMMIT>`.
|
||||
|
||||
### A2. Write the SDK-side changes
|
||||
|
||||
In `software-agent-sdk`, implement the client code (e.g., new methods on `OpenHandsCloudWorkspace`). Run SDK unit tests:
|
||||
|
||||
```bash
|
||||
cd software-agent-sdk
|
||||
pip install -e openhands-sdk -e openhands-workspace
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
Push a PR. SDK CI is independent — it doesn't need the server changes to pass unit tests.
|
||||
|
||||
### A3. Deploy the server PR to staging
|
||||
|
||||
See [Deploying to a Staging Feature Environment](#deploying-to-a-staging-feature-environment) below.
|
||||
|
||||
### A4. Run the SDK e2e test against staging
|
||||
|
||||
See [Running E2E Tests Against Staging](#running-e2e-tests-against-staging) below.
|
||||
|
||||
---
|
||||
|
||||
## Flow B: OH Server Needs Unreleased SDK Code
|
||||
|
||||
Use this when the Cloud server depends on SDK changes that haven't been released to PyPI yet. The server's runtime containers run the `agent-server` image built from the SDK repo, so the server PR must be configured to use the SDK PR's image and packages.
|
||||
|
||||
### B1. Get the SDK PR merged (or identify the commit)
|
||||
|
||||
The SDK PR must have CI pass so its agent-server Docker image is built. The image is tagged with the **merge-commit SHA** from GitHub Actions — NOT the head-commit SHA shown in the PR.
|
||||
|
||||
Find the correct image tag:
|
||||
- Check the SDK PR description for an `AGENT_SERVER_IMAGES` section
|
||||
- Or check the "Consolidate Build Information" CI job for `"short_sha": "<tag>"`
|
||||
|
||||
### B2. Pin SDK packages to the commit in the OpenHands PR
|
||||
|
||||
In the `OpenHands` repo PR, pin all 3 SDK packages (`openhands-sdk`, `openhands-agent-server`, `openhands-tools`) to the unreleased commit and update the agent-server image tag. This involves editing 3 files and regenerating 3 lock files.
|
||||
|
||||
Follow the **`update-sdk` skill** → "Development: Pin SDK to an Unreleased Commit" section for the full procedure and file-by-file instructions.
|
||||
|
||||
### B3. Wait for the OpenHands enterprise image to build
|
||||
|
||||
Push the pinned changes. The OpenHands CI will build a new enterprise Docker image (`ghcr.io/openhands/enterprise-server:sha-<OH_COMMIT>`) that bundles the unreleased SDK. Wait for the "Push Enterprise Image" job to succeed.
|
||||
|
||||
### B4. Deploy and test
|
||||
|
||||
Follow [Deploying to a Staging Feature Environment](#deploying-to-a-staging-feature-environment) using the new OpenHands commit SHA.
|
||||
|
||||
### B5. Before merging: remove the pin
|
||||
|
||||
**CI guard:** `check-package-versions.yml` blocks merge to `main` if `[tool.poetry.dependencies]` contains `rev` fields. Before the OpenHands PR can merge, the SDK PR must be merged and released to PyPI, then the pin must be replaced with the released version number.
|
||||
|
||||
---
|
||||
|
||||
## Deploying to a Staging Feature Environment
|
||||
|
||||
The `deploy` repo creates preview environments from OpenHands PRs.
|
||||
|
||||
**Option A — GitHub Actions UI (preferred):**
|
||||
Go to `OpenHands/deploy` → Actions → "Create OpenHands preview PR" → enter the OpenHands PR number. This creates a branch `ohpr-<PR>-<random>` and opens a deploy PR.
|
||||
|
||||
**Option B — Update an existing feature branch:**
|
||||
```bash
|
||||
cd deploy
|
||||
git checkout ohpr-<PR>-<random>
|
||||
# In .github/workflows/deploy.yaml, update BOTH:
|
||||
# OPENHANDS_SHA: "<full-40-char-commit>"
|
||||
# OPENHANDS_RUNTIME_IMAGE_TAG: "<same-commit>-nikolaik"
|
||||
git commit -am "Update OPENHANDS_SHA to <commit>" && git push
|
||||
```
|
||||
|
||||
**Before updating the SHA**, verify the enterprise Docker image exists:
|
||||
```bash
|
||||
gh api repos/OpenHands/OpenHands/actions/runs \
|
||||
--jq '.workflow_runs[] | select(.head_sha=="<COMMIT>") | "\(.name): \(.conclusion)"' \
|
||||
| grep Docker
|
||||
# Must show: "Docker: success"
|
||||
```
|
||||
|
||||
The deploy CI auto-triggers and creates the environment at:
|
||||
```
|
||||
https://ohpr-<PR>-<random>.staging.all-hands.dev
|
||||
```
|
||||
|
||||
**Wait for it to be live:**
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" https://ohpr-<PR>-<random>.staging.all-hands.dev/api/v1/health
|
||||
# 401 = server is up (auth required). DNS may take 1-2 min on first deploy.
|
||||
```
|
||||
|
||||
## Running E2E Tests Against Staging
|
||||
|
||||
**Critical: Feature deployments have their own Keycloak instance.** API keys from `app.all-hands.dev` or `$OPENHANDS_API_KEY` will NOT work. You need a test API key issued by the specific feature deployment's Keycloak.
|
||||
|
||||
**You (the agent) cannot obtain this key yourself** — the feature environment requires interactive browser login with credentials you do not have. You must **ask the user** to:
|
||||
1. Log in to the feature deployment at `https://ohpr-<PR>-<random>.staging.all-hands.dev` in their browser
|
||||
2. Generate a test API key from the UI
|
||||
3. Provide the key to you so you can proceed with e2e testing
|
||||
|
||||
Do **not** attempt to log in via the browser or guess credentials. Wait for the user to supply the key before running any e2e tests.
|
||||
|
||||
```python
|
||||
from openhands.workspace import OpenHandsCloudWorkspace
|
||||
|
||||
STAGING = "https://ohpr-<PR>-<random>.staging.all-hands.dev"
|
||||
|
||||
with OpenHandsCloudWorkspace(
|
||||
cloud_api_url=STAGING,
|
||||
cloud_api_key="<test-api-key-for-this-deployment>",
|
||||
) as workspace:
|
||||
# Test the new feature
|
||||
llm = workspace.get_llm()
|
||||
secrets = workspace.get_secrets()
|
||||
print(f"LLM: {llm.model}, secrets: {list(secrets.keys())}")
|
||||
```
|
||||
|
||||
Or run an example script:
|
||||
```bash
|
||||
OPENHANDS_CLOUD_API_KEY="<key>" \
|
||||
OPENHANDS_CLOUD_API_URL="https://ohpr-<PR>-<random>.staging.all-hands.dev" \
|
||||
python examples/02_remote_agent_server/10_cloud_workspace_saas_credentials.py
|
||||
```
|
||||
|
||||
### Recording results
|
||||
|
||||
Both repos support a `.pr/` directory for temporary PR artifacts (design docs, test logs, scripts). These files are automatically removed when the PR is approved — see `.github/workflows/pr-artifacts.yml` and the "PR-Specific Artifacts" section in each repo's `AGENTS.md`.
|
||||
|
||||
Push test output to the `.pr/logs/` directory of whichever repo you're working in:
|
||||
```bash
|
||||
mkdir -p .pr/logs
|
||||
python test_script.py 2>&1 | tee .pr/logs/<test_name>.log
|
||||
git add -f .pr/logs/
|
||||
git commit -m "docs: add e2e test results" && git push
|
||||
```
|
||||
|
||||
Comment on **both PRs** with pass/fail summary and link to logs.
|
||||
|
||||
## Key Gotchas
|
||||
|
||||
| Gotcha | Details |
|
||||
|--------|---------|
|
||||
| **Feature env auth is isolated** | Each `ohpr-*` deployment has its own Keycloak. Production API keys don't work. Agents cannot log in — you must ask the user to provide a test API key from the feature deployment's UI. |
|
||||
| **Two SHAs in deploy.yaml** | `OPENHANDS_SHA` and `OPENHANDS_RUNTIME_IMAGE_TAG` must both be updated. The runtime tag is `<sha>-nikolaik`. |
|
||||
| **Enterprise image must exist** | The Docker CI job on the OpenHands PR must succeed before you can deploy. If it hasn't run, push an empty commit to trigger it. |
|
||||
| **DNS propagation** | First deployment of a new branch takes 1-2 min for DNS. Subsequent deploys are instant. |
|
||||
| **Merge-commit SHA ≠ head SHA** | SDK CI tags Docker images with GitHub Actions' merge-commit SHA, not the PR head SHA. Check the SDK PR description or CI logs for the correct tag. |
|
||||
| **SDK pin blocks merge** | `check-package-versions.yml` prevents merging an OpenHands PR that has `rev` fields in `[tool.poetry.dependencies]`. The SDK must be released to PyPI first. |
|
||||
| **Flow A: stock agent-server is fine** | When only the Cloud API changes, `OpenHandsCloudWorkspace` talks to the Cloud server, not the agent-server. No custom image needed. |
|
||||
| **Flow B: agent-server image is required** | When the server needs new SDK code inside runtime containers, you must pin to the SDK PR's agent-server image. |
|
||||
@@ -1,136 +0,0 @@
|
||||
---
|
||||
name: PR Artifacts
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Manual trigger for testing
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches: [main]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
# Auto-remove .pr/ directory when a reviewer approves
|
||||
cleanup-on-approval:
|
||||
concurrency:
|
||||
group: cleanup-pr-artifacts-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check if fork PR
|
||||
id: check-fork
|
||||
run: |
|
||||
if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.event.pull_request.base.repo.full_name }}" ]; then
|
||||
echo "is_fork=true" >> $GITHUB_OUTPUT
|
||||
echo "::notice::Fork PR detected - skipping auto-cleanup (manual removal required)"
|
||||
else
|
||||
echo "is_fork=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.check-fork.outputs.is_fork == 'false'
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
|
||||
|
||||
- name: Remove .pr/ directory
|
||||
id: remove
|
||||
if: steps.check-fork.outputs.is_fork == 'false'
|
||||
run: |
|
||||
if [ -d ".pr" ]; then
|
||||
git config user.name "allhands-bot"
|
||||
git config user.email "allhands-bot@users.noreply.github.com"
|
||||
git rm -rf .pr/
|
||||
git commit -m "chore: Remove PR-only artifacts [automated]"
|
||||
git push || {
|
||||
echo "::error::Failed to push cleanup commit. Check branch protection rules."
|
||||
exit 1
|
||||
}
|
||||
echo "removed=true" >> $GITHUB_OUTPUT
|
||||
echo "::notice::Removed .pr/ directory"
|
||||
else
|
||||
echo "removed=false" >> $GITHUB_OUTPUT
|
||||
echo "::notice::No .pr/ directory to remove"
|
||||
fi
|
||||
|
||||
- name: Update PR comment after cleanup
|
||||
if: steps.check-fork.outputs.is_fork == 'false' && steps.remove.outputs.removed == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- pr-artifacts-notice -->';
|
||||
const body = `${marker}
|
||||
✅ **PR Artifacts Cleaned Up**
|
||||
|
||||
The \`.pr/\` directory has been automatically removed.
|
||||
`;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body: body,
|
||||
});
|
||||
}
|
||||
|
||||
# Warn if .pr/ directory exists (will be auto-removed on approval)
|
||||
check-pr-artifacts:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Check for .pr/ directory
|
||||
id: check
|
||||
run: |
|
||||
if [ -d ".pr" ]; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "::warning::.pr/ directory exists and will be automatically removed when the PR is approved. For fork PRs, manual removal is required before merging."
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Post or update PR comment
|
||||
if: steps.check.outputs.exists == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- pr-artifacts-notice -->';
|
||||
const body = `${marker}
|
||||
📁 **PR Artifacts Notice**
|
||||
|
||||
This PR contains a \`.pr/\` directory with PR-specific documents. This directory will be **automatically removed** when the PR is approved.
|
||||
|
||||
> For fork PRs: Manual removal is required before merging.
|
||||
`;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (!existing) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: body,
|
||||
});
|
||||
}
|
||||
@@ -36,40 +36,6 @@ then re-run the command to ensure it passes. Common issues include:
|
||||
- Be especially careful with `git reset --hard` after staging files, as it will remove accidentally staged files
|
||||
- When remote has new changes, use `git fetch upstream && git rebase upstream/<branch>` on the same branch
|
||||
|
||||
## PR-Specific Artifacts (`.pr/` directory)
|
||||
|
||||
When working on a PR that requires design documents, scripts meant for development-only, or other temporary artifacts that should NOT be merged to main, store them in a `.pr/` directory at the repository root.
|
||||
|
||||
### Usage
|
||||
|
||||
```
|
||||
.pr/
|
||||
├── design.md # Design decisions and architecture notes
|
||||
├── analysis.md # Investigation or debugging notes
|
||||
├── logs/ # Test output or CI logs for reviewer reference
|
||||
└── notes.md # Any other PR-specific content
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Notification**: When `.pr/` exists, a comment is posted to the PR conversation alerting reviewers
|
||||
2. **Auto-cleanup**: When the PR is approved, the `.pr/` directory is automatically removed via `.github/workflows/pr-artifacts.yml`
|
||||
3. **Fork PRs**: Auto-cleanup cannot push to forks, so manual removal is required before merging
|
||||
|
||||
### Important Notes
|
||||
|
||||
- Do NOT put anything in `.pr/` that needs to be preserved after merge
|
||||
- The `.pr/` check passes (green ✅) during development — it only posts a notification, not a blocking error
|
||||
- For fork PRs: You must manually remove `.pr/` before the PR can be merged
|
||||
|
||||
### When to Use
|
||||
|
||||
- Complex refactoring that benefits from written design rationale
|
||||
- Debugging sessions where you want to document your investigation
|
||||
- E2E test results or logs that demonstrate a cross-repo feature works
|
||||
- Feature implementations that need temporary planning docs
|
||||
- Any analysis that helps reviewers understand the PR but isn't needed long-term
|
||||
|
||||
## Repository Structure
|
||||
Backend:
|
||||
- Located in the `openhands` directory
|
||||
|
||||
@@ -125,17 +125,6 @@ For example, a PR title could be:
|
||||
- If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix),
|
||||
please include a short message that we can add to our changelog
|
||||
|
||||
## Becoming a Maintainer
|
||||
|
||||
For contributors who have made significant and sustained contributions to the project, there is a possibility of joining the maintainer team.
|
||||
The process for this is as follows:
|
||||
|
||||
1. Any contributor who has made sustained and high-quality contributions to the codebase can be nominated by any maintainer. If you feel that you may qualify you can reach out to any of the maintainers that have reviewed your PRs and ask if you can be nominated.
|
||||
2. Once a maintainer nominates a new maintainer, there will be a discussion period among the maintainers for at least 3 days.
|
||||
3. If no concerns are raised the nomination will be accepted by acclamation, and if concerns are raised there will be a discussion and possible vote.
|
||||
|
||||
Note that just making many PRs does not immediately imply that you will become a maintainer. We will be looking at sustained high-quality contributions over a period of time, as well as good teamwork and adherence to our [Code of Conduct](./CODE_OF_CONDUCT.md).
|
||||
|
||||
## Need Help?
|
||||
|
||||
- **Slack**: [Join our community](https://openhands.dev/joinslack)
|
||||
|
||||
Generated
+6
-6
@@ -5443,14 +5443,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.26.0"
|
||||
version = "1.25.0"
|
||||
description = "Model Context Protocol SDK"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca"},
|
||||
{file = "mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66"},
|
||||
{file = "mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a"},
|
||||
{file = "mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -7597,14 +7597,14 @@ wrappers-encryption = ["cryptography (>=45.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.3"
|
||||
version = "0.6.2"
|
||||
description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"},
|
||||
{file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"},
|
||||
{file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"},
|
||||
{file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -46,7 +46,6 @@ from server.routes.org_invitations import ( # noqa: E402
|
||||
)
|
||||
from server.routes.orgs import org_router # noqa: E402
|
||||
from server.routes.readiness import readiness_router # noqa: E402
|
||||
from server.routes.service import service_router # noqa: E402
|
||||
from server.routes.user import saas_user_router # noqa: E402
|
||||
from server.routes.user_app_settings import user_app_settings_router # noqa: E402
|
||||
from server.sharing.shared_conversation_router import ( # noqa: E402
|
||||
@@ -113,7 +112,6 @@ if GITLAB_APP_CLIENT_ID:
|
||||
base_app.include_router(gitlab_integration_router)
|
||||
|
||||
base_app.include_router(api_keys_router) # Add routes for API key management
|
||||
base_app.include_router(service_router) # Add routes for internal service API
|
||||
base_app.include_router(org_router) # Add routes for organization management
|
||||
base_app.include_router(
|
||||
verified_models_router
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
"""
|
||||
Admin authentication utilities for enterprise endpoints.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
from storage.database import a_session_maker
|
||||
from storage.user import User
|
||||
|
||||
|
||||
async def get_admin_user_id(user_id: str | None = Depends(get_user_id)) -> str:
|
||||
"""
|
||||
Dependency that validates user has the admin role.
|
||||
|
||||
This dependency can be used in place of get_user_id for endpoints that
|
||||
should only be accessible to admin users. The admin role is checked
|
||||
against the User table's role relationship.
|
||||
|
||||
Args:
|
||||
user_id: User ID from get_user_id dependency
|
||||
|
||||
Returns:
|
||||
str: User ID if user has admin role
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 if user does not have admin role
|
||||
HTTPException: 401 if user is not authenticated
|
||||
|
||||
Example:
|
||||
@router.post('/endpoint')
|
||||
async def create_resource(
|
||||
user_id: str = Depends(get_admin_user_id),
|
||||
):
|
||||
# Only admin users can access this endpoint
|
||||
pass
|
||||
"""
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User not authenticated',
|
||||
)
|
||||
|
||||
async with a_session_maker() as session:
|
||||
from sqlalchemy import select
|
||||
import uuid
|
||||
|
||||
result = await session.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.role))
|
||||
.filter(User.id == uuid.UUID(user_id))
|
||||
)
|
||||
user = result.scalars().first()
|
||||
|
||||
if not user:
|
||||
logger.warning(
|
||||
'Access denied - user not found',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Access restricted to admin users',
|
||||
)
|
||||
|
||||
if not user.role or user.role.name != 'admin':
|
||||
logger.warning(
|
||||
'Access denied - user is not an admin',
|
||||
extra={'user_id': user_id, 'role': user.role.name if user.role else None},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Access restricted to admin users',
|
||||
)
|
||||
|
||||
return user_id
|
||||
@@ -1,7 +1,6 @@
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from uuid import UUID
|
||||
|
||||
import jwt
|
||||
from fastapi import Request
|
||||
@@ -60,10 +59,6 @@ class SaasUserAuth(UserAuth):
|
||||
_secrets: Secrets | None = None
|
||||
accepted_tos: bool | None = None
|
||||
auth_type: AuthType = AuthType.COOKIE
|
||||
# API key context fields - populated when authenticated via API key
|
||||
api_key_org_id: UUID | None = None
|
||||
api_key_id: int | None = None
|
||||
api_key_name: str | None = None
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return self.user_id
|
||||
@@ -288,19 +283,14 @@ async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
|
||||
return None
|
||||
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
validation_result = await api_key_store.validate_api_key(api_key)
|
||||
if not validation_result:
|
||||
user_id = await api_key_store.validate_api_key(api_key)
|
||||
if not user_id:
|
||||
return None
|
||||
offline_token = await token_manager.load_offline_token(
|
||||
validation_result.user_id
|
||||
)
|
||||
offline_token = await token_manager.load_offline_token(user_id)
|
||||
saas_user_auth = SaasUserAuth(
|
||||
user_id=validation_result.user_id,
|
||||
user_id=user_id,
|
||||
refresh_token=SecretStr(offline_token),
|
||||
auth_type=AuthType.BEARER,
|
||||
api_key_org_id=validation_result.org_id,
|
||||
api_key_id=validation_result.key_id,
|
||||
api_key_name=validation_result.key_name,
|
||||
)
|
||||
await saas_user_auth.refresh()
|
||||
return saas_user_auth
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Email domain validation utilities for enterprise endpoints.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_auth, get_user_id
|
||||
|
||||
|
||||
async def get_admin_user_id(
|
||||
request: Request, user_id: str | None = Depends(get_user_id)
|
||||
) -> str:
|
||||
"""
|
||||
Dependency that validates user has @openhands.dev email domain.
|
||||
|
||||
This dependency can be used in place of get_user_id for endpoints that
|
||||
should only be accessible to admin users. Currently, this is implemented
|
||||
by checking for @openhands.dev email domain.
|
||||
|
||||
TODO: In the future, this should be replaced with an explicit is_admin flag
|
||||
in user/org settings instead of relying on email domain validation.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
user_id: User ID from get_user_id dependency
|
||||
|
||||
Returns:
|
||||
str: User ID if email domain is valid
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 if email domain is not @openhands.dev
|
||||
HTTPException: 401 if user is not authenticated
|
||||
|
||||
Example:
|
||||
@router.post('/endpoint')
|
||||
async def create_resource(
|
||||
user_id: str = Depends(get_admin_user_id),
|
||||
):
|
||||
# Only admin users can access this endpoint
|
||||
pass
|
||||
"""
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User not authenticated',
|
||||
)
|
||||
|
||||
user_auth = await get_user_auth(request)
|
||||
user_email = await user_auth.get_user_email()
|
||||
|
||||
if not user_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User email not available',
|
||||
)
|
||||
|
||||
if not user_email.endswith('@openhands.dev'):
|
||||
logger.warning(
|
||||
'Access denied - invalid email domain',
|
||||
extra={'user_id': user_id, 'email_domain': user_email.split('@')[-1]},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Access restricted to @openhands.dev users',
|
||||
)
|
||||
|
||||
return user_id
|
||||
@@ -182,10 +182,6 @@ class SetAuthCookieMiddleware:
|
||||
if path.startswith('/api/v1/webhooks/'):
|
||||
return False
|
||||
|
||||
# Service API uses its own authentication (X-Service-API-Key header)
|
||||
if path.startswith('/api/service/'):
|
||||
return False
|
||||
|
||||
is_mcp = path.startswith('/mcp')
|
||||
is_api_route = path.startswith('/api')
|
||||
return is_api_route or is_mcp
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import cast
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, field_validator
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from storage.api_key import ApiKey
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
@@ -13,8 +11,7 @@ from storage.org_service import OrgService
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_auth, get_user_id
|
||||
from openhands.server.user_auth.user_auth import AuthType
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
|
||||
# Helper functions for BYOR API key management
|
||||
@@ -153,16 +150,6 @@ class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class CurrentApiKeyResponse(BaseModel):
|
||||
"""Response model for the current API key endpoint."""
|
||||
|
||||
id: int
|
||||
name: str | None
|
||||
org_id: str
|
||||
user_id: str
|
||||
auth_type: str
|
||||
|
||||
|
||||
def api_key_to_response(key: ApiKey) -> ApiKeyResponse:
|
||||
"""Convert an ApiKey model to an ApiKeyResponse."""
|
||||
return ApiKeyResponse(
|
||||
@@ -275,46 +262,6 @@ async def delete_api_key(
|
||||
)
|
||||
|
||||
|
||||
@api_router.get('/current', tags=['Keys'])
|
||||
async def get_current_api_key(
|
||||
request: Request,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> CurrentApiKeyResponse:
|
||||
"""Get information about the currently authenticated API key.
|
||||
|
||||
This endpoint returns metadata about the API key used for the current request,
|
||||
including the org_id associated with the key. This is useful for API key
|
||||
callers who need to know which organization context their key operates in.
|
||||
|
||||
Returns 400 if not authenticated via API key (e.g., using cookie auth).
|
||||
"""
|
||||
user_auth = await get_user_auth(request)
|
||||
|
||||
# Check if authenticated via API key
|
||||
if user_auth.get_auth_type() != AuthType.BEARER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This endpoint requires API key authentication. Not available for cookie-based auth.',
|
||||
)
|
||||
|
||||
# In SaaS context, bearer auth always produces SaasUserAuth
|
||||
saas_user_auth = cast(SaasUserAuth, user_auth)
|
||||
|
||||
if saas_user_auth.api_key_org_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This API key was created before organization support. Please regenerate your API key to use this endpoint.',
|
||||
)
|
||||
|
||||
return CurrentApiKeyResponse(
|
||||
id=saas_user_auth.api_key_id,
|
||||
name=saas_user_auth.api_key_name,
|
||||
org_id=str(saas_user_auth.api_key_org_id),
|
||||
user_id=user_id,
|
||||
auth_type=saas_user_auth.auth_type.value,
|
||||
)
|
||||
|
||||
|
||||
@api_router.get('/llm/byor', tags=['Keys'])
|
||||
async def get_llm_api_key_for_byor(
|
||||
user_id: str = Depends(get_user_id),
|
||||
|
||||
@@ -2,11 +2,11 @@ from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from server.auth.admin_auth import get_admin_user_id
|
||||
from server.auth.authorization import (
|
||||
Permission,
|
||||
require_permission,
|
||||
)
|
||||
from server.email_validation import get_admin_user_id
|
||||
from server.routes.org_models import (
|
||||
CannotModifySelfError,
|
||||
InsufficientPermissionError,
|
||||
@@ -149,8 +149,9 @@ async def create_org(
|
||||
) -> OrgResponse:
|
||||
"""Create a new organization.
|
||||
|
||||
This endpoint allows authenticated admin users to create a new organization.
|
||||
The user who creates the organization automatically becomes its owner.
|
||||
This endpoint allows authenticated users with @openhands.dev email to create
|
||||
a new organization. The user who creates the organization automatically becomes
|
||||
its owner.
|
||||
|
||||
Args:
|
||||
org_data: Organization creation data
|
||||
@@ -160,7 +161,7 @@ async def create_org(
|
||||
OrgResponse: The created organization details
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 if user does not have admin role
|
||||
HTTPException: 403 if user email domain is not @openhands.dev
|
||||
HTTPException: 409 if organization name already exists
|
||||
HTTPException: 500 if creation fails
|
||||
"""
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
"""
|
||||
Service API routes for internal service-to-service communication.
|
||||
|
||||
This module provides endpoints for trusted internal services (e.g., automations service)
|
||||
to perform privileged operations like creating API keys on behalf of users.
|
||||
|
||||
Authentication is via a shared secret (X-Service-API-Key header) configured
|
||||
through the AUTOMATIONS_SERVICE_API_KEY environment variable.
|
||||
"""
|
||||
|
||||
import os
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, status
|
||||
from pydantic import BaseModel, field_validator
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
# Environment variable for the service API key
|
||||
AUTOMATIONS_SERVICE_API_KEY = os.getenv('AUTOMATIONS_SERVICE_API_KEY', '').strip()
|
||||
|
||||
service_router = APIRouter(prefix='/api/service', tags=['Service'])
|
||||
|
||||
|
||||
class CreateUserApiKeyRequest(BaseModel):
|
||||
"""Request model for creating an API key on behalf of a user."""
|
||||
|
||||
name: str # Required - used to identify the key
|
||||
|
||||
@field_validator('name')
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
if not v or not v.strip():
|
||||
raise ValueError('name is required and cannot be empty')
|
||||
return v.strip()
|
||||
|
||||
|
||||
class CreateUserApiKeyResponse(BaseModel):
|
||||
"""Response model for created API key."""
|
||||
|
||||
key: str
|
||||
user_id: str
|
||||
org_id: str
|
||||
name: str
|
||||
|
||||
|
||||
class ServiceInfoResponse(BaseModel):
|
||||
"""Response model for service info endpoint."""
|
||||
|
||||
service: str
|
||||
authenticated: bool
|
||||
|
||||
|
||||
async def validate_service_api_key(
|
||||
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
|
||||
) -> str:
|
||||
"""
|
||||
Validate the service API key from the request header.
|
||||
|
||||
Args:
|
||||
x_service_api_key: The service API key from the X-Service-API-Key header
|
||||
|
||||
Returns:
|
||||
str: Service identifier for audit logging
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if key is missing or invalid
|
||||
HTTPException: 503 if service auth is not configured
|
||||
"""
|
||||
if not AUTOMATIONS_SERVICE_API_KEY:
|
||||
logger.warning(
|
||||
'Service authentication not configured (AUTOMATIONS_SERVICE_API_KEY not set)'
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail='Service authentication not configured',
|
||||
)
|
||||
|
||||
if not x_service_api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='X-Service-API-Key header is required',
|
||||
)
|
||||
|
||||
if x_service_api_key != AUTOMATIONS_SERVICE_API_KEY:
|
||||
logger.warning('Invalid service API key attempted')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='Invalid service API key',
|
||||
)
|
||||
|
||||
return 'automations-service'
|
||||
|
||||
|
||||
@service_router.get('/health')
|
||||
async def service_health() -> dict:
|
||||
"""Health check endpoint for the service API.
|
||||
|
||||
This endpoint does not require authentication and can be used
|
||||
to verify the service routes are accessible.
|
||||
"""
|
||||
return {
|
||||
'status': 'ok',
|
||||
'service_auth_configured': bool(AUTOMATIONS_SERVICE_API_KEY),
|
||||
}
|
||||
|
||||
|
||||
@service_router.post('/users/{user_id}/orgs/{org_id}/api-keys')
|
||||
async def get_or_create_api_key_for_user(
|
||||
user_id: str,
|
||||
org_id: UUID,
|
||||
request: CreateUserApiKeyRequest,
|
||||
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
|
||||
) -> CreateUserApiKeyResponse:
|
||||
"""
|
||||
Get or create an API key for a user on behalf of the automations service.
|
||||
|
||||
If a key with the given name already exists for the user/org and is not expired,
|
||||
returns the existing key. Otherwise, creates a new key.
|
||||
|
||||
The created/returned keys are system keys and are:
|
||||
- Not visible to the user in their API keys list
|
||||
- Not deletable by the user
|
||||
- Never expire
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
org_id: The organization ID
|
||||
request: Request body containing name (required)
|
||||
x_service_api_key: Service API key header for authentication
|
||||
|
||||
Returns:
|
||||
CreateUserApiKeyResponse: The API key and metadata
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if service key is invalid
|
||||
HTTPException: 404 if user not found
|
||||
HTTPException: 403 if user is not a member of the specified org
|
||||
"""
|
||||
# Validate service API key
|
||||
service_id = await validate_service_api_key(x_service_api_key)
|
||||
|
||||
# Verify user exists
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
if not user:
|
||||
logger.warning(
|
||||
'Service attempted to create key for non-existent user',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'User {user_id} not found',
|
||||
)
|
||||
|
||||
# Verify user is a member of the specified org
|
||||
org_member = await OrgMemberStore.get_org_member(org_id, UUID(user_id))
|
||||
if not org_member:
|
||||
logger.warning(
|
||||
'Service attempted to create key for user not in org',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f'User {user_id} is not a member of org {org_id}',
|
||||
)
|
||||
|
||||
# Get or create the system API key
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
|
||||
try:
|
||||
api_key = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=request.name,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Failed to get or create system API key',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to get or create API key',
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Service created API key for user',
|
||||
extra={
|
||||
'service_id': service_id,
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'key_name': request.name,
|
||||
},
|
||||
)
|
||||
|
||||
return CreateUserApiKeyResponse(
|
||||
key=api_key,
|
||||
user_id=user_id,
|
||||
org_id=str(org_id),
|
||||
name=request.name,
|
||||
)
|
||||
|
||||
|
||||
@service_router.delete('/users/{user_id}/orgs/{org_id}/api-keys/{key_name}')
|
||||
async def delete_user_api_key(
|
||||
user_id: str,
|
||||
org_id: UUID,
|
||||
key_name: str,
|
||||
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
|
||||
) -> dict:
|
||||
"""
|
||||
Delete a system API key created by the service.
|
||||
|
||||
This endpoint allows the automations service to clean up API keys
|
||||
it previously created for users.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
org_id: The organization ID
|
||||
key_name: The name of the key to delete (without __SYSTEM__: prefix)
|
||||
x_service_api_key: Service API key header for authentication
|
||||
|
||||
Returns:
|
||||
dict: Success message
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if service key is invalid
|
||||
HTTPException: 404 if key not found
|
||||
"""
|
||||
# Validate service API key
|
||||
service_id = await validate_service_api_key(x_service_api_key)
|
||||
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
|
||||
# Delete the key by name (wrap with system key prefix since service creates system keys)
|
||||
system_key_name = api_key_store.make_system_key_name(key_name)
|
||||
success = await api_key_store.delete_api_key_by_name(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=system_key_name,
|
||||
allow_system=True,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'API key with name "{key_name}" not found for user {user_id} in org {org_id}',
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Service deleted API key for user',
|
||||
extra={
|
||||
'service_id': service_id,
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'key_name': key_name,
|
||||
},
|
||||
)
|
||||
|
||||
return {'message': 'API key deleted successfully'}
|
||||
@@ -3,7 +3,7 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from server.auth.admin_auth import get_admin_user_id
|
||||
from server.email_validation import get_admin_user_id
|
||||
from server.verified_models.verified_model_models import (
|
||||
VerifiedModel,
|
||||
VerifiedModelCreate,
|
||||
|
||||
@@ -4,7 +4,6 @@ import secrets
|
||||
import string
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from storage.api_key import ApiKey
|
||||
@@ -14,22 +13,9 @@ from storage.user_store import UserStore
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiKeyValidationResult:
|
||||
"""Result of API key validation containing user and org context."""
|
||||
|
||||
user_id: str
|
||||
org_id: UUID | None
|
||||
key_id: int
|
||||
key_name: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiKeyStore:
|
||||
API_KEY_PREFIX = 'sk-oh-'
|
||||
# Prefix for system keys created by internal services (e.g., automations)
|
||||
# Keys with this prefix are hidden from users and cannot be deleted by users
|
||||
SYSTEM_KEY_NAME_PREFIX = '__SYSTEM__:'
|
||||
|
||||
def generate_api_key(self, length: int = 32) -> str:
|
||||
"""Generate a random API key with the sk-oh- prefix."""
|
||||
@@ -37,19 +23,6 @@ class ApiKeyStore:
|
||||
random_part = ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
return f'{self.API_KEY_PREFIX}{random_part}'
|
||||
|
||||
@classmethod
|
||||
def is_system_key_name(cls, name: str | None) -> bool:
|
||||
"""Check if a key name indicates a system key."""
|
||||
return name is not None and name.startswith(cls.SYSTEM_KEY_NAME_PREFIX)
|
||||
|
||||
@classmethod
|
||||
def make_system_key_name(cls, name: str) -> str:
|
||||
"""Create a system key name with the appropriate prefix.
|
||||
|
||||
Format: __SYSTEM__:<name>
|
||||
"""
|
||||
return f'{cls.SYSTEM_KEY_NAME_PREFIX}{name}'
|
||||
|
||||
async def create_api_key(
|
||||
self, user_id: str, name: str | None = None, expires_at: datetime | None = None
|
||||
) -> str:
|
||||
@@ -87,115 +60,8 @@ class ApiKeyStore:
|
||||
|
||||
return api_key
|
||||
|
||||
async def get_or_create_system_api_key(
|
||||
self,
|
||||
user_id: str,
|
||||
org_id: UUID,
|
||||
name: str,
|
||||
) -> str:
|
||||
"""Get or create a system API key for a user on behalf of an internal service.
|
||||
|
||||
If a key with the given name already exists for this user/org and is not expired,
|
||||
returns the existing key. Otherwise, creates a new key (and deletes any expired one).
|
||||
|
||||
System keys are:
|
||||
- Not visible to users in their API keys list (filtered by name prefix)
|
||||
- Not deletable by users (protected by name prefix check)
|
||||
- Associated with a specific org (not the user's current org)
|
||||
- Never expire (no expiration date)
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user to create the key for
|
||||
org_id: The organization ID to associate the key with
|
||||
name: Required name for the key (will be prefixed with __SYSTEM__:)
|
||||
|
||||
Returns:
|
||||
The API key (existing or newly created)
|
||||
"""
|
||||
# Create system key name with prefix
|
||||
system_key_name = self.make_system_key_name(name)
|
||||
|
||||
async with a_session_maker() as session:
|
||||
# Check if key already exists for this user/org/name
|
||||
result = await session.execute(
|
||||
select(ApiKey).filter(
|
||||
ApiKey.user_id == user_id,
|
||||
ApiKey.org_id == org_id,
|
||||
ApiKey.name == system_key_name,
|
||||
)
|
||||
)
|
||||
existing_key = result.scalars().first()
|
||||
|
||||
if existing_key:
|
||||
# Check if expired
|
||||
if existing_key.expires_at:
|
||||
now = datetime.now(UTC)
|
||||
expires_at = existing_key.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
|
||||
if expires_at < now:
|
||||
# Key is expired, delete it and create new one
|
||||
logger.info(
|
||||
'System API key expired, re-issuing',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'key_name': system_key_name,
|
||||
},
|
||||
)
|
||||
await session.delete(existing_key)
|
||||
await session.commit()
|
||||
else:
|
||||
# Key exists and is not expired, return it
|
||||
logger.debug(
|
||||
'Returning existing system API key',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'key_name': system_key_name,
|
||||
},
|
||||
)
|
||||
return existing_key.key
|
||||
else:
|
||||
# Key exists and has no expiration, return it
|
||||
logger.debug(
|
||||
'Returning existing system API key',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'key_name': system_key_name,
|
||||
},
|
||||
)
|
||||
return existing_key.key
|
||||
|
||||
# Create new key (no expiration)
|
||||
api_key = self.generate_api_key()
|
||||
|
||||
async with a_session_maker() as session:
|
||||
key_record = ApiKey(
|
||||
key=api_key,
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=system_key_name,
|
||||
expires_at=None, # System keys never expire
|
||||
)
|
||||
session.add(key_record)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
'Created system API key',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'key_name': system_key_name,
|
||||
},
|
||||
)
|
||||
|
||||
return api_key
|
||||
|
||||
async def validate_api_key(self, api_key: str) -> ApiKeyValidationResult | None:
|
||||
"""Validate an API key and return the associated user_id and org_id if valid."""
|
||||
async def validate_api_key(self, api_key: str) -> str | None:
|
||||
"""Validate an API key and return the associated user_id if valid."""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
async with a_session_maker() as session:
|
||||
@@ -223,12 +89,7 @@ class ApiKeyStore:
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return ApiKeyValidationResult(
|
||||
user_id=key_record.user_id,
|
||||
org_id=key_record.org_id,
|
||||
key_id=key_record.id,
|
||||
key_name=key_record.name,
|
||||
)
|
||||
return key_record.user_id
|
||||
|
||||
async def delete_api_key(self, api_key: str) -> bool:
|
||||
"""Delete an API key by the key value."""
|
||||
@@ -244,18 +105,8 @@ class ApiKeyStore:
|
||||
|
||||
return True
|
||||
|
||||
async def delete_api_key_by_id(
|
||||
self, key_id: int, allow_system: bool = False
|
||||
) -> bool:
|
||||
"""Delete an API key by its ID.
|
||||
|
||||
Args:
|
||||
key_id: The ID of the key to delete
|
||||
allow_system: If False (default), system keys cannot be deleted
|
||||
|
||||
Returns:
|
||||
True if the key was deleted, False if not found or is a protected system key
|
||||
"""
|
||||
async def delete_api_key_by_id(self, key_id: int) -> bool:
|
||||
"""Delete an API key by its ID."""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id))
|
||||
key_record = result.scalars().first()
|
||||
@@ -263,26 +114,13 @@ class ApiKeyStore:
|
||||
if not key_record:
|
||||
return False
|
||||
|
||||
# Protect system keys from deletion unless explicitly allowed
|
||||
if self.is_system_key_name(key_record.name) and not allow_system:
|
||||
logger.warning(
|
||||
'Attempted to delete system API key',
|
||||
extra={'key_id': key_id, 'user_id': key_record.user_id},
|
||||
)
|
||||
return False
|
||||
|
||||
await session.delete(key_record)
|
||||
await session.commit()
|
||||
|
||||
return True
|
||||
|
||||
async def list_api_keys(self, user_id: str) -> list[ApiKey]:
|
||||
"""List all user-visible API keys for a user.
|
||||
|
||||
This excludes:
|
||||
- System keys (name starts with __SYSTEM__:) - created by internal services
|
||||
- MCP_API_KEY - internal MCP key
|
||||
"""
|
||||
"""List all API keys for a user."""
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
if user is None:
|
||||
raise ValueError(f'User not found: {user_id}')
|
||||
@@ -291,17 +129,11 @@ class ApiKeyStore:
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(ApiKey).filter(
|
||||
ApiKey.user_id == user_id,
|
||||
ApiKey.org_id == org_id,
|
||||
ApiKey.user_id == user_id, ApiKey.org_id == org_id
|
||||
)
|
||||
)
|
||||
keys = result.scalars().all()
|
||||
# Filter out system keys and MCP_API_KEY
|
||||
return [
|
||||
key
|
||||
for key in keys
|
||||
if key.name != 'MCP_API_KEY' and not self.is_system_key_name(key.name)
|
||||
]
|
||||
return [key for key in keys if key.name != 'MCP_API_KEY']
|
||||
|
||||
async def retrieve_mcp_api_key(self, user_id: str) -> str | None:
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
@@ -331,44 +163,17 @@ class ApiKeyStore:
|
||||
key_record = result.scalars().first()
|
||||
return key_record.key if key_record else None
|
||||
|
||||
async def delete_api_key_by_name(
|
||||
self,
|
||||
user_id: str,
|
||||
name: str,
|
||||
org_id: UUID | None = None,
|
||||
allow_system: bool = False,
|
||||
) -> bool:
|
||||
"""Delete an API key by name for a specific user.
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user whose key to delete
|
||||
name: The name of the key to delete
|
||||
org_id: Optional organization ID to filter by (required for system keys)
|
||||
allow_system: If False (default), system keys cannot be deleted
|
||||
|
||||
Returns:
|
||||
True if the key was deleted, False if not found or is a protected system key
|
||||
"""
|
||||
async def delete_api_key_by_name(self, user_id: str, name: str) -> bool:
|
||||
"""Delete an API key by name for a specific user."""
|
||||
async with a_session_maker() as session:
|
||||
# Build the query filters
|
||||
filters = [ApiKey.user_id == user_id, ApiKey.name == name]
|
||||
if org_id is not None:
|
||||
filters.append(ApiKey.org_id == org_id)
|
||||
|
||||
result = await session.execute(select(ApiKey).filter(*filters))
|
||||
result = await session.execute(
|
||||
select(ApiKey).filter(ApiKey.user_id == user_id, ApiKey.name == name)
|
||||
)
|
||||
key_record = result.scalars().first()
|
||||
|
||||
if not key_record:
|
||||
return False
|
||||
|
||||
# Protect system keys from deletion unless explicitly allowed
|
||||
if self.is_system_key_name(key_record.name) and not allow_system:
|
||||
logger.warning(
|
||||
'Attempted to delete system API key',
|
||||
extra={'user_id': user_id, 'key_name': name},
|
||||
)
|
||||
return False
|
||||
|
||||
await session.delete(key_record)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -29,37 +29,14 @@ KEY_VERIFICATION_TIMEOUT = 5.0
|
||||
# A very large number to represent "unlimited" until LiteLLM fixes their unlimited update bug.
|
||||
UNLIMITED_BUDGET_SETTING = 1000000000.0
|
||||
|
||||
# Check if billing is enabled (defaults to false for enterprise deployments)
|
||||
ENABLE_BILLING = os.environ.get('ENABLE_BILLING', 'false').lower() == 'true'
|
||||
|
||||
|
||||
def _get_default_initial_budget() -> float | None:
|
||||
"""Get the default initial budget for new teams.
|
||||
|
||||
When billing is disabled (ENABLE_BILLING=false), returns None to disable
|
||||
budget enforcement in LiteLLM. When billing is enabled, returns the
|
||||
DEFAULT_INITIAL_BUDGET environment variable value (default 0.0).
|
||||
|
||||
Returns:
|
||||
float | None: The default budget, or None to disable budget enforcement.
|
||||
"""
|
||||
if not ENABLE_BILLING:
|
||||
return None
|
||||
|
||||
try:
|
||||
budget = float(os.environ.get('DEFAULT_INITIAL_BUDGET', 0.0))
|
||||
if budget < 0:
|
||||
raise ValueError(
|
||||
f'DEFAULT_INITIAL_BUDGET must be non-negative, got {budget}'
|
||||
)
|
||||
return budget
|
||||
except ValueError as e:
|
||||
try:
|
||||
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', 0.0))
|
||||
if DEFAULT_INITIAL_BUDGET < 0:
|
||||
raise ValueError(
|
||||
f'Invalid DEFAULT_INITIAL_BUDGET environment variable: {e}'
|
||||
) from e
|
||||
|
||||
|
||||
DEFAULT_INITIAL_BUDGET: float | None = _get_default_initial_budget()
|
||||
f'DEFAULT_INITIAL_BUDGET must be non-negative, got {DEFAULT_INITIAL_BUDGET}'
|
||||
)
|
||||
except ValueError as e:
|
||||
raise ValueError(f'Invalid DEFAULT_INITIAL_BUDGET environment variable: {e}') from e
|
||||
|
||||
|
||||
def get_openhands_cloud_key_alias(keycloak_user_id: str, org_id: str) -> str:
|
||||
@@ -133,15 +110,12 @@ class LiteLlmManager:
|
||||
) as client:
|
||||
# Check if team already exists and get its budget
|
||||
# New users joining existing orgs should inherit the team's budget
|
||||
# When billing is disabled, DEFAULT_INITIAL_BUDGET is None
|
||||
team_budget: float | None = DEFAULT_INITIAL_BUDGET
|
||||
team_budget: float = DEFAULT_INITIAL_BUDGET
|
||||
try:
|
||||
existing_team = await LiteLlmManager._get_team(client, org_id)
|
||||
if existing_team:
|
||||
team_info = existing_team.get('team_info', {})
|
||||
# Preserve None from existing team (no budget enforcement)
|
||||
existing_budget = team_info.get('max_budget')
|
||||
team_budget = existing_budget
|
||||
team_budget = team_info.get('max_budget', 0.0) or 0.0
|
||||
logger.info(
|
||||
'LiteLlmManager:create_entries:existing_team_budget',
|
||||
extra={
|
||||
@@ -551,17 +525,8 @@ class LiteLlmManager:
|
||||
client: httpx.AsyncClient,
|
||||
team_alias: str,
|
||||
team_id: str,
|
||||
max_budget: float | None,
|
||||
max_budget: float,
|
||||
):
|
||||
"""Create a new team in LiteLLM.
|
||||
|
||||
Args:
|
||||
client: The HTTP client to use.
|
||||
team_alias: The alias for the team.
|
||||
team_id: The ID for the team.
|
||||
max_budget: The maximum budget for the team. When None, budget
|
||||
enforcement is disabled (unlimited usage).
|
||||
"""
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
logger.warning('LiteLLM API configuration not found')
|
||||
return
|
||||
@@ -571,7 +536,7 @@ class LiteLlmManager:
|
||||
'team_id': team_id,
|
||||
'team_alias': team_alias,
|
||||
'models': [],
|
||||
'max_budget': max_budget, # None disables budget enforcement
|
||||
'max_budget': max_budget,
|
||||
'spend': 0,
|
||||
'metadata': {
|
||||
'version': ORG_SETTINGS_VERSION,
|
||||
@@ -953,17 +918,8 @@ class LiteLlmManager:
|
||||
client: httpx.AsyncClient,
|
||||
keycloak_user_id: str,
|
||||
team_id: str,
|
||||
max_budget: float | None,
|
||||
max_budget: float,
|
||||
):
|
||||
"""Add a user to a team in LiteLLM.
|
||||
|
||||
Args:
|
||||
client: The HTTP client to use.
|
||||
keycloak_user_id: The user's Keycloak ID.
|
||||
team_id: The team ID.
|
||||
max_budget: The maximum budget for the user in the team. When None,
|
||||
budget enforcement is disabled (unlimited usage).
|
||||
"""
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
logger.warning('LiteLLM API configuration not found')
|
||||
return
|
||||
@@ -972,7 +928,7 @@ class LiteLlmManager:
|
||||
json={
|
||||
'team_id': team_id,
|
||||
'member': {'user_id': keycloak_user_id, 'role': 'user'},
|
||||
'max_budget_in_team': max_budget, # None disables budget enforcement
|
||||
'max_budget_in_team': max_budget,
|
||||
},
|
||||
)
|
||||
# Failed to add user to team - this is an unforseen error state...
|
||||
@@ -1042,17 +998,8 @@ class LiteLlmManager:
|
||||
client: httpx.AsyncClient,
|
||||
keycloak_user_id: str,
|
||||
team_id: str,
|
||||
max_budget: float | None,
|
||||
max_budget: float,
|
||||
):
|
||||
"""Update a user's budget in a team.
|
||||
|
||||
Args:
|
||||
client: The HTTP client to use.
|
||||
keycloak_user_id: The user's Keycloak ID.
|
||||
team_id: The team ID.
|
||||
max_budget: The maximum budget for the user in the team. When None,
|
||||
budget enforcement is disabled (unlimited usage).
|
||||
"""
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
logger.warning('LiteLLM API configuration not found')
|
||||
return
|
||||
@@ -1061,7 +1008,7 @@ class LiteLlmManager:
|
||||
json={
|
||||
'team_id': team_id,
|
||||
'user_id': keycloak_user_id,
|
||||
'max_budget_in_team': max_budget, # None disables budget enforcement
|
||||
'max_budget_in_team': max_budget,
|
||||
},
|
||||
)
|
||||
# Failed to update user in team - this is an unforseen error state...
|
||||
|
||||
@@ -28,14 +28,12 @@ class SaasConversationValidator(ConversationValidator):
|
||||
|
||||
# Validate the API key and get the user_id
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
validation_result = await api_key_store.validate_api_key(api_key)
|
||||
user_id = await api_key_store.validate_api_key(api_key)
|
||||
|
||||
if not validation_result:
|
||||
if not user_id:
|
||||
logger.warning('Invalid API key')
|
||||
return None
|
||||
|
||||
user_id = validation_result.user_id
|
||||
|
||||
# Get the offline token for the user
|
||||
offline_token = await token_manager.load_offline_token(user_id)
|
||||
if not offline_token:
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
"""Unit tests for service API routes."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from server.routes.service import (
|
||||
CreateUserApiKeyRequest,
|
||||
delete_user_api_key,
|
||||
get_or_create_api_key_for_user,
|
||||
validate_service_api_key,
|
||||
)
|
||||
|
||||
|
||||
class TestValidateServiceApiKey:
|
||||
"""Test cases for validate_service_api_key."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_service_key(self):
|
||||
"""Test validation with valid service API key."""
|
||||
with patch(
|
||||
'server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-service-key'
|
||||
):
|
||||
result = await validate_service_api_key('test-service-key')
|
||||
assert result == 'automations-service'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_service_key(self):
|
||||
"""Test validation with missing service API key header."""
|
||||
with patch(
|
||||
'server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-service-key'
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_service_api_key(None)
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'X-Service-API-Key header is required' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_service_key(self):
|
||||
"""Test validation with invalid service API key."""
|
||||
with patch(
|
||||
'server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-service-key'
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_service_api_key('wrong-key')
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'Invalid service API key' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_auth_not_configured(self):
|
||||
"""Test validation when service auth is not configured."""
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', ''):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await validate_service_api_key('any-key')
|
||||
assert exc_info.value.status_code == 503
|
||||
assert 'Service authentication not configured' in exc_info.value.detail
|
||||
|
||||
|
||||
class TestCreateUserApiKeyRequest:
|
||||
"""Test cases for CreateUserApiKeyRequest validation."""
|
||||
|
||||
def test_valid_request(self):
|
||||
"""Test valid request with all fields."""
|
||||
request = CreateUserApiKeyRequest(
|
||||
name='automation',
|
||||
)
|
||||
assert request.name == 'automation'
|
||||
|
||||
def test_name_is_required(self):
|
||||
"""Test that name field is required."""
|
||||
with pytest.raises(ValueError):
|
||||
CreateUserApiKeyRequest(
|
||||
name='', # Empty name should fail
|
||||
)
|
||||
|
||||
def test_name_is_stripped(self):
|
||||
"""Test that name field is stripped of whitespace."""
|
||||
request = CreateUserApiKeyRequest(
|
||||
name=' automation ',
|
||||
)
|
||||
assert request.name == 'automation'
|
||||
|
||||
def test_whitespace_only_name_fails(self):
|
||||
"""Test that whitespace-only name fails validation."""
|
||||
with pytest.raises(ValueError):
|
||||
CreateUserApiKeyRequest(
|
||||
name=' ',
|
||||
)
|
||||
|
||||
|
||||
class TestGetOrCreateApiKeyForUser:
|
||||
"""Test cases for get_or_create_api_key_for_user endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def valid_user_id(self):
|
||||
"""Return a valid user ID."""
|
||||
return '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
|
||||
@pytest.fixture
|
||||
def valid_org_id(self):
|
||||
"""Return a valid org ID."""
|
||||
return uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
@pytest.fixture
|
||||
def valid_request(self):
|
||||
"""Create a valid request object."""
|
||||
return CreateUserApiKeyRequest(
|
||||
name='automation',
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_not_found(self, valid_user_id, valid_org_id, valid_request):
|
||||
"""Test error when user doesn't exist."""
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
|
||||
with patch(
|
||||
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
|
||||
) as mock_get_user:
|
||||
mock_get_user.return_value = None
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_or_create_api_key_for_user(
|
||||
user_id=valid_user_id,
|
||||
org_id=valid_org_id,
|
||||
request=valid_request,
|
||||
x_service_api_key='test-key',
|
||||
)
|
||||
assert exc_info.value.status_code == 404
|
||||
assert 'not found' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_not_in_org(self, valid_user_id, valid_org_id, valid_request):
|
||||
"""Test error when user is not a member of the org."""
|
||||
mock_user = MagicMock()
|
||||
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
|
||||
with patch(
|
||||
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
|
||||
) as mock_get_user:
|
||||
with patch(
|
||||
'server.routes.service.OrgMemberStore.get_org_member',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_member:
|
||||
mock_get_user.return_value = mock_user
|
||||
mock_get_member.return_value = None
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_or_create_api_key_for_user(
|
||||
user_id=valid_user_id,
|
||||
org_id=valid_org_id,
|
||||
request=valid_request,
|
||||
x_service_api_key='test-key',
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
assert 'not a member of org' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_key_creation(
|
||||
self, valid_user_id, valid_org_id, valid_request
|
||||
):
|
||||
"""Test successful API key creation."""
|
||||
mock_user = MagicMock()
|
||||
mock_org_member = MagicMock()
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.get_or_create_system_api_key = AsyncMock(
|
||||
return_value='sk-oh-test-key-12345678901234567890'
|
||||
)
|
||||
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
|
||||
with patch(
|
||||
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
|
||||
) as mock_get_user:
|
||||
with patch(
|
||||
'server.routes.service.OrgMemberStore.get_org_member',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_member:
|
||||
with patch(
|
||||
'server.routes.service.ApiKeyStore.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_get_user.return_value = mock_user
|
||||
mock_get_member.return_value = mock_org_member
|
||||
mock_get_store.return_value = mock_api_key_store
|
||||
|
||||
response = await get_or_create_api_key_for_user(
|
||||
user_id=valid_user_id,
|
||||
org_id=valid_org_id,
|
||||
request=valid_request,
|
||||
x_service_api_key='test-key',
|
||||
)
|
||||
|
||||
assert response.key == 'sk-oh-test-key-12345678901234567890'
|
||||
assert response.user_id == valid_user_id
|
||||
assert response.org_id == str(valid_org_id)
|
||||
assert response.name == 'automation'
|
||||
|
||||
# Verify the store was called with correct arguments
|
||||
mock_api_key_store.get_or_create_system_api_key.assert_called_once_with(
|
||||
user_id=valid_user_id,
|
||||
org_id=valid_org_id,
|
||||
name='automation',
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_exception_handling(
|
||||
self, valid_user_id, valid_org_id, valid_request
|
||||
):
|
||||
"""Test error handling when store raises exception."""
|
||||
mock_user = MagicMock()
|
||||
mock_org_member = MagicMock()
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.get_or_create_system_api_key = AsyncMock(
|
||||
side_effect=Exception('Database error')
|
||||
)
|
||||
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
|
||||
with patch(
|
||||
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
|
||||
) as mock_get_user:
|
||||
with patch(
|
||||
'server.routes.service.OrgMemberStore.get_org_member',
|
||||
new_callable=AsyncMock,
|
||||
) as mock_get_member:
|
||||
with patch(
|
||||
'server.routes.service.ApiKeyStore.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_get_user.return_value = mock_user
|
||||
mock_get_member.return_value = mock_org_member
|
||||
mock_get_store.return_value = mock_api_key_store
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_or_create_api_key_for_user(
|
||||
user_id=valid_user_id,
|
||||
org_id=valid_org_id,
|
||||
request=valid_request,
|
||||
x_service_api_key='test-key',
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert 'Failed to get or create API key' in exc_info.value.detail
|
||||
|
||||
|
||||
class TestDeleteUserApiKey:
|
||||
"""Test cases for delete_user_api_key endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def valid_org_id(self):
|
||||
"""Return a valid org ID."""
|
||||
return uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_delete(self, valid_org_id):
|
||||
"""Test successful deletion of a system API key."""
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.make_system_key_name.return_value = '__SYSTEM__:automation'
|
||||
mock_api_key_store.delete_api_key_by_name = AsyncMock(return_value=True)
|
||||
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
|
||||
with patch(
|
||||
'server.routes.service.ApiKeyStore.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_get_store.return_value = mock_api_key_store
|
||||
|
||||
response = await delete_user_api_key(
|
||||
user_id='user-123',
|
||||
org_id=valid_org_id,
|
||||
key_name='automation',
|
||||
x_service_api_key='test-key',
|
||||
)
|
||||
|
||||
assert response == {'message': 'API key deleted successfully'}
|
||||
|
||||
# Verify the store was called with correct arguments
|
||||
mock_api_key_store.make_system_key_name.assert_called_once_with('automation')
|
||||
mock_api_key_store.delete_api_key_by_name.assert_called_once_with(
|
||||
user_id='user-123',
|
||||
org_id=valid_org_id,
|
||||
name='__SYSTEM__:automation',
|
||||
allow_system=True,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_key_not_found(self, valid_org_id):
|
||||
"""Test error when key to delete is not found."""
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.make_system_key_name.return_value = '__SYSTEM__:nonexistent'
|
||||
mock_api_key_store.delete_api_key_by_name = AsyncMock(return_value=False)
|
||||
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
|
||||
with patch(
|
||||
'server.routes.service.ApiKeyStore.get_instance'
|
||||
) as mock_get_store:
|
||||
mock_get_store.return_value = mock_api_key_store
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await delete_user_api_key(
|
||||
user_id='user-123',
|
||||
org_id=valid_org_id,
|
||||
key_name='nonexistent',
|
||||
x_service_api_key='test-key',
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert 'not found' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_invalid_service_key(self, valid_org_id):
|
||||
"""Test error when service API key is invalid."""
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await delete_user_api_key(
|
||||
user_id='user-123',
|
||||
org_id=valid_org_id,
|
||||
key_name='automation',
|
||||
x_service_api_key='wrong-key',
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'Invalid service API key' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_missing_service_key(self, valid_org_id):
|
||||
"""Test error when service API key header is missing."""
|
||||
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await delete_user_api_key(
|
||||
user_id='user-123',
|
||||
org_id=valid_org_id,
|
||||
key_name='automation',
|
||||
x_service_api_key=None,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'X-Service-API-Key header is required' in exc_info.value.detail
|
||||
@@ -1,286 +0,0 @@
|
||||
"""
|
||||
Unit tests for admin authentication dependency (get_admin_user_id).
|
||||
|
||||
Tests the FastAPI dependency that validates user has admin role.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from server.auth.admin_auth import get_admin_user_id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user():
|
||||
"""Create a mock User object."""
|
||||
user = MagicMock()
|
||||
user.id = uuid.uuid4()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_admin_role():
|
||||
"""Create a mock admin Role object."""
|
||||
role = MagicMock()
|
||||
role.name = 'admin'
|
||||
return role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_role():
|
||||
"""Create a mock regular user Role object."""
|
||||
role = MagicMock()
|
||||
role.name = 'user'
|
||||
return role
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_owner_role():
|
||||
"""Create a mock owner Role object."""
|
||||
role = MagicMock()
|
||||
role.name = 'owner'
|
||||
return role
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_admin_user_id_success(mock_user, mock_admin_role):
|
||||
"""
|
||||
GIVEN: Valid user ID and user has admin role
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: User ID is returned successfully
|
||||
"""
|
||||
# Arrange
|
||||
user_id = str(uuid.uuid4())
|
||||
mock_user.role = mock_admin_role
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = mock_user
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch('server.auth.admin_auth.a_session_maker', return_value=mock_session):
|
||||
# Act
|
||||
result = await get_admin_user_id(user_id)
|
||||
|
||||
# Assert
|
||||
assert result == user_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_admin_user_id_no_user_id():
|
||||
"""
|
||||
GIVEN: No user ID provided (None)
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: 401 Unauthorized is raised
|
||||
"""
|
||||
# Arrange
|
||||
user_id = None
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_admin_user_id(user_id)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'not authenticated' in exc_info.value.detail.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_admin_user_id_empty_string_user_id():
|
||||
"""
|
||||
GIVEN: Empty string user ID
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: 401 Unauthorized is raised
|
||||
"""
|
||||
# Arrange
|
||||
user_id = ''
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_admin_user_id(user_id)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'not authenticated' in exc_info.value.detail.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_admin_user_id_user_not_found():
|
||||
"""
|
||||
GIVEN: User ID provided but user does not exist in database
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: 403 Forbidden is raised
|
||||
"""
|
||||
# Arrange
|
||||
user_id = str(uuid.uuid4())
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = None
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch('server.auth.admin_auth.a_session_maker', return_value=mock_session):
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_admin_user_id(user_id)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert 'admin' in exc_info.value.detail.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_admin_user_id_user_no_role(mock_user):
|
||||
"""
|
||||
GIVEN: User ID and user has no role assigned
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: 403 Forbidden is raised
|
||||
"""
|
||||
# Arrange
|
||||
user_id = str(uuid.uuid4())
|
||||
mock_user.role = None
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = mock_user
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch('server.auth.admin_auth.a_session_maker', return_value=mock_session):
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_admin_user_id(user_id)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert 'admin' in exc_info.value.detail.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_admin_user_id_user_is_regular_user(mock_user, mock_user_role):
|
||||
"""
|
||||
GIVEN: User ID and user has 'user' role (not admin)
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: 403 Forbidden is raised
|
||||
"""
|
||||
# Arrange
|
||||
user_id = str(uuid.uuid4())
|
||||
mock_user.role = mock_user_role
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = mock_user
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch('server.auth.admin_auth.a_session_maker', return_value=mock_session):
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_admin_user_id(user_id)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert 'admin' in exc_info.value.detail.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_admin_user_id_user_is_owner(mock_user, mock_owner_role):
|
||||
"""
|
||||
GIVEN: User ID and user has 'owner' role (not admin)
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: 403 Forbidden is raised (owner is not admin)
|
||||
"""
|
||||
# Arrange
|
||||
user_id = str(uuid.uuid4())
|
||||
mock_user.role = mock_owner_role
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = mock_user
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch('server.auth.admin_auth.a_session_maker', return_value=mock_session):
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_admin_user_id(user_id)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert 'admin' in exc_info.value.detail.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_admin_user_id_logs_warning_on_non_admin(mock_user, mock_user_role):
|
||||
"""
|
||||
GIVEN: User with non-admin role
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: Warning is logged with user_id and role
|
||||
"""
|
||||
# Arrange
|
||||
user_id = str(uuid.uuid4())
|
||||
mock_user.role = mock_user_role
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = mock_user
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch('server.auth.admin_auth.a_session_maker', return_value=mock_session),
|
||||
patch('server.auth.admin_auth.logger') as mock_logger,
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException):
|
||||
await get_admin_user_id(user_id)
|
||||
|
||||
# Verify warning was logged
|
||||
mock_logger.warning.assert_called_once()
|
||||
call_args = mock_logger.warning.call_args
|
||||
assert 'not an admin' in call_args[0][0]
|
||||
assert call_args[1]['extra']['user_id'] == user_id
|
||||
assert call_args[1]['extra']['role'] == 'user'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_admin_user_id_logs_warning_on_user_not_found():
|
||||
"""
|
||||
GIVEN: User that does not exist in database
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: Warning is logged indicating user not found
|
||||
"""
|
||||
# Arrange
|
||||
user_id = str(uuid.uuid4())
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = None
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute = AsyncMock(return_value=mock_result)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch('server.auth.admin_auth.a_session_maker', return_value=mock_session),
|
||||
patch('server.auth.admin_auth.logger') as mock_logger,
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException):
|
||||
await get_admin_user_id(user_id)
|
||||
|
||||
# Verify warning was logged
|
||||
mock_logger.warning.assert_called_once()
|
||||
call_args = mock_logger.warning.call_args
|
||||
assert 'user not found' in call_args[0][0]
|
||||
assert call_args[1]['extra']['user_id'] == user_id
|
||||
@@ -1,26 +1,19 @@
|
||||
"""Unit tests for API keys routes, focusing on BYOR key validation and retrieval."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from pydantic import SecretStr
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.routes.api_keys import (
|
||||
ByorPermittedResponse,
|
||||
CurrentApiKeyResponse,
|
||||
LlmApiKeyResponse,
|
||||
check_byor_permitted,
|
||||
delete_byor_key_from_litellm,
|
||||
get_current_api_key,
|
||||
get_llm_api_key_for_byor,
|
||||
)
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
|
||||
from openhands.server.user_auth.user_auth import AuthType
|
||||
|
||||
|
||||
class TestVerifyByorKeyInLitellm:
|
||||
"""Test the verify_byor_key_in_litellm function."""
|
||||
@@ -519,81 +512,3 @@ class TestCheckByorPermitted:
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert 'Failed to check BYOR export permission' in exc_info.value.detail
|
||||
|
||||
|
||||
class TestGetCurrentApiKey:
|
||||
"""Test the get_current_api_key endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.get_user_auth')
|
||||
async def test_returns_api_key_info_for_bearer_auth(self, mock_get_user_auth):
|
||||
"""Test that API key metadata including org_id is returned for bearer token auth."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
org_id = uuid.uuid4()
|
||||
mock_request = MagicMock()
|
||||
|
||||
user_auth = SaasUserAuth(
|
||||
refresh_token=SecretStr('mock-token'),
|
||||
user_id=user_id,
|
||||
auth_type=AuthType.BEARER,
|
||||
api_key_org_id=org_id,
|
||||
api_key_id=42,
|
||||
api_key_name='My Production Key',
|
||||
)
|
||||
mock_get_user_auth.return_value = user_auth
|
||||
|
||||
# Act
|
||||
result = await get_current_api_key(request=mock_request, user_id=user_id)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, CurrentApiKeyResponse)
|
||||
assert result.org_id == str(org_id)
|
||||
assert result.id == 42
|
||||
assert result.name == 'My Production Key'
|
||||
assert result.user_id == user_id
|
||||
assert result.auth_type == 'bearer'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.get_user_auth')
|
||||
async def test_returns_400_for_cookie_auth(self, mock_get_user_auth):
|
||||
"""Test that 400 Bad Request is returned when using cookie authentication."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
mock_request = MagicMock()
|
||||
|
||||
mock_user_auth = MagicMock()
|
||||
mock_user_auth.get_auth_type.return_value = AuthType.COOKIE
|
||||
mock_get_user_auth.return_value = mock_user_auth
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_api_key(request=mock_request, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert 'API key authentication' in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('server.routes.api_keys.get_user_auth')
|
||||
async def test_returns_400_when_api_key_org_id_is_none(self, mock_get_user_auth):
|
||||
"""Test that 400 is returned when API key has no org_id (legacy key)."""
|
||||
# Arrange
|
||||
user_id = 'user-123'
|
||||
mock_request = MagicMock()
|
||||
|
||||
user_auth = SaasUserAuth(
|
||||
refresh_token=SecretStr('mock-token'),
|
||||
user_id=user_id,
|
||||
auth_type=AuthType.BEARER,
|
||||
api_key_org_id=None, # No org_id - legacy key
|
||||
api_key_id=42,
|
||||
api_key_name='Legacy Key',
|
||||
)
|
||||
mock_get_user_auth.return_value = user_auth
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_current_api_key(request=mock_request, user_id=user_id)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert 'created before organization support' in exc_info.value.detail
|
||||
|
||||
@@ -11,7 +11,7 @@ import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException, Request, status
|
||||
from fastapi.testclient import TestClient
|
||||
from server.auth.admin_auth import get_admin_user_id
|
||||
from server.email_validation import get_admin_user_id
|
||||
from server.routes.org_models import (
|
||||
CannotModifySelfError,
|
||||
InsufficientPermissionError,
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
"""Unit tests for ApiKeyStore system key functionality."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from storage.api_key import ApiKey
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_key_store():
|
||||
"""Create ApiKeyStore instance."""
|
||||
return ApiKeyStore()
|
||||
|
||||
|
||||
class TestApiKeyStoreSystemKeys:
|
||||
"""Test cases for system API key functionality."""
|
||||
|
||||
def test_is_system_key_name_with_prefix(self, api_key_store):
|
||||
"""Test that names with __SYSTEM__: prefix are identified as system keys."""
|
||||
assert api_key_store.is_system_key_name('__SYSTEM__:automation') is True
|
||||
assert api_key_store.is_system_key_name('__SYSTEM__:test-key') is True
|
||||
assert api_key_store.is_system_key_name('__SYSTEM__:') is True
|
||||
|
||||
def test_is_system_key_name_without_prefix(self, api_key_store):
|
||||
"""Test that names without __SYSTEM__: prefix are not system keys."""
|
||||
assert api_key_store.is_system_key_name('my-key') is False
|
||||
assert api_key_store.is_system_key_name('automation') is False
|
||||
assert api_key_store.is_system_key_name('MCP_API_KEY') is False
|
||||
assert api_key_store.is_system_key_name('') is False
|
||||
|
||||
def test_is_system_key_name_none(self, api_key_store):
|
||||
"""Test that None is not a system key."""
|
||||
assert api_key_store.is_system_key_name(None) is False
|
||||
|
||||
def test_make_system_key_name(self, api_key_store):
|
||||
"""Test system key name generation."""
|
||||
assert (
|
||||
api_key_store.make_system_key_name('automation') == '__SYSTEM__:automation'
|
||||
)
|
||||
assert api_key_store.make_system_key_name('test-key') == '__SYSTEM__:test-key'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_system_api_key_creates_new(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test creating a new system API key when none exists."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
key_name = 'automation'
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
api_key = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=key_name,
|
||||
)
|
||||
|
||||
assert api_key.startswith('sk-oh-')
|
||||
assert len(api_key) == len('sk-oh-') + 32
|
||||
|
||||
# Verify the key was created in the database
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(select(ApiKey).filter(ApiKey.key == api_key))
|
||||
key_record = result.scalars().first()
|
||||
assert key_record is not None
|
||||
assert key_record.user_id == user_id
|
||||
assert key_record.org_id == org_id
|
||||
assert key_record.name == '__SYSTEM__:automation'
|
||||
assert key_record.expires_at is None # System keys never expire
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_system_api_key_returns_existing(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that existing valid system key is returned."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
key_name = 'automation'
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
# Create the first key
|
||||
first_key = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=key_name,
|
||||
)
|
||||
|
||||
# Request again - should return the same key
|
||||
second_key = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=key_name,
|
||||
)
|
||||
|
||||
assert first_key == second_key
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_system_api_key_different_names(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that different names create different keys."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
key1 = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='automation-1',
|
||||
)
|
||||
|
||||
key2 = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='automation-2',
|
||||
)
|
||||
|
||||
assert key1 != key2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_or_create_system_api_key_reissues_expired(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that expired system key is replaced with a new one."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
key_name = 'automation'
|
||||
system_key_name = '__SYSTEM__:automation'
|
||||
|
||||
# First, manually create an expired key
|
||||
expired_time = datetime.now(UTC) - timedelta(hours=1)
|
||||
async with async_session_maker() as session:
|
||||
expired_key = ApiKey(
|
||||
key='sk-oh-expired-key-12345678901234567890',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=system_key_name,
|
||||
expires_at=expired_time.replace(tzinfo=None),
|
||||
)
|
||||
session.add(expired_key)
|
||||
await session.commit()
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
# Request the key - should create a new one
|
||||
new_key = await api_key_store.get_or_create_system_api_key(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=key_name,
|
||||
)
|
||||
|
||||
assert new_key != 'sk-oh-expired-key-12345678901234567890'
|
||||
assert new_key.startswith('sk-oh-')
|
||||
|
||||
# Verify old key was deleted and new key exists
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(ApiKey).filter(ApiKey.name == system_key_name)
|
||||
)
|
||||
keys = result.scalars().all()
|
||||
assert len(keys) == 1
|
||||
assert keys[0].key == new_key
|
||||
assert keys[0].expires_at is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_api_keys_excludes_system_keys(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that list_api_keys excludes system keys."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
# Create a user key and a system key
|
||||
async with async_session_maker() as session:
|
||||
user_key = ApiKey(
|
||||
key='sk-oh-user-key-123456789012345678901',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='my-user-key',
|
||||
)
|
||||
system_key = ApiKey(
|
||||
key='sk-oh-system-key-12345678901234567890',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='__SYSTEM__:automation',
|
||||
)
|
||||
mcp_key = ApiKey(
|
||||
key='sk-oh-mcp-key-1234567890123456789012',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='MCP_API_KEY',
|
||||
)
|
||||
session.add(user_key)
|
||||
session.add(system_key)
|
||||
session.add(mcp_key)
|
||||
await session.commit()
|
||||
|
||||
# Mock UserStore.get_user_by_id to return a user with the correct org
|
||||
mock_user = MagicMock()
|
||||
mock_user.current_org_id = org_id
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
with patch(
|
||||
'storage.api_key_store.UserStore.get_user_by_id', new_callable=AsyncMock
|
||||
) as mock_get_user:
|
||||
mock_get_user.return_value = mock_user
|
||||
keys = await api_key_store.list_api_keys(user_id)
|
||||
|
||||
# Should only return the user key
|
||||
assert len(keys) == 1
|
||||
assert keys[0].name == 'my-user-key'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_api_key_by_id_protects_system_keys(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that system keys cannot be deleted by users."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
# Create a system key
|
||||
async with async_session_maker() as session:
|
||||
system_key = ApiKey(
|
||||
key='sk-oh-system-key-12345678901234567890',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='__SYSTEM__:automation',
|
||||
)
|
||||
session.add(system_key)
|
||||
await session.commit()
|
||||
key_id = system_key.id
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
# Attempt to delete without allow_system flag
|
||||
result = await api_key_store.delete_api_key_by_id(
|
||||
key_id, allow_system=False
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
# Verify the key still exists
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id))
|
||||
key_record = result.scalars().first()
|
||||
assert key_record is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_api_key_by_id_allows_system_with_flag(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that system keys can be deleted with allow_system=True."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
# Create a system key
|
||||
async with async_session_maker() as session:
|
||||
system_key = ApiKey(
|
||||
key='sk-oh-system-key-12345678901234567890',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='__SYSTEM__:automation',
|
||||
)
|
||||
session.add(system_key)
|
||||
await session.commit()
|
||||
key_id = system_key.id
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
# Delete with allow_system=True
|
||||
result = await api_key_store.delete_api_key_by_id(key_id, allow_system=True)
|
||||
|
||||
assert result is True
|
||||
|
||||
# Verify the key was deleted
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id))
|
||||
key_record = result.scalars().first()
|
||||
assert key_record is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_api_key_by_id_allows_regular_keys(
|
||||
self, api_key_store, async_session_maker
|
||||
):
|
||||
"""Test that regular keys can be deleted normally."""
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
|
||||
# Create a regular key
|
||||
async with async_session_maker() as session:
|
||||
regular_key = ApiKey(
|
||||
key='sk-oh-regular-key-1234567890123456789',
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name='my-regular-key',
|
||||
)
|
||||
session.add(regular_key)
|
||||
await session.commit()
|
||||
key_id = regular_key.id
|
||||
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
# Delete without allow_system flag - should work for regular keys
|
||||
result = await api_key_store.delete_api_key_by_id(
|
||||
key_id, allow_system=False
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
# Verify the key was deleted
|
||||
async with async_session_maker() as session:
|
||||
result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id))
|
||||
key_record = result.scalars().first()
|
||||
assert key_record is None
|
||||
@@ -126,18 +126,13 @@ async def test_validate_api_key_valid(api_key_store, async_session_maker):
|
||||
)
|
||||
session.add(key_record)
|
||||
await session.commit()
|
||||
key_id = key_record.id
|
||||
|
||||
# Execute - patch a_session_maker to use test's async session maker
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
result = await api_key_store.validate_api_key(api_key_value)
|
||||
|
||||
# Verify - result is now ApiKeyValidationResult
|
||||
assert result is not None
|
||||
assert result.user_id == user_id
|
||||
assert result.org_id == org_id
|
||||
assert result.key_id == key_id
|
||||
assert result.key_name == 'Test Key'
|
||||
# Verify
|
||||
assert result == user_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -223,9 +218,8 @@ async def test_validate_api_key_valid_timezone_naive(
|
||||
with patch('storage.api_key_store.a_session_maker', async_session_maker):
|
||||
result = await api_key_store.validate_api_key(api_key_value)
|
||||
|
||||
# Verify - result is now ApiKeyValidationResult
|
||||
assert result is not None
|
||||
assert result.user_id == user_id
|
||||
# Verify
|
||||
assert result == user_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
Unit tests for email validation dependency (get_admin_user_id).
|
||||
|
||||
Tests the FastAPI dependency that validates @openhands.dev email domain.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request
|
||||
from server.email_validation import get_admin_user_id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request():
|
||||
"""Create a mock FastAPI request."""
|
||||
return MagicMock(spec=Request)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_auth():
|
||||
"""Create a mock user auth object."""
|
||||
mock_auth = AsyncMock()
|
||||
mock_auth.get_user_email = AsyncMock()
|
||||
return mock_auth
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_openhands_user_id_success(mock_request, mock_user_auth):
|
||||
"""
|
||||
GIVEN: Valid user ID and @openhands.dev email
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: User ID is returned successfully
|
||||
"""
|
||||
# Arrange
|
||||
user_id = 'test-user-123'
|
||||
mock_user_auth.get_user_email.return_value = 'test@openhands.dev'
|
||||
|
||||
with patch('server.email_validation.get_user_auth', return_value=mock_user_auth):
|
||||
# Act
|
||||
result = await get_admin_user_id(mock_request, user_id)
|
||||
|
||||
# Assert
|
||||
assert result == user_id
|
||||
mock_user_auth.get_user_email.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_openhands_user_id_no_user_id(mock_request):
|
||||
"""
|
||||
GIVEN: No user ID provided (None)
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: 401 Unauthorized is raised
|
||||
"""
|
||||
# Arrange
|
||||
user_id = None
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_admin_user_id(mock_request, user_id)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'not authenticated' in exc_info.value.detail.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_openhands_user_id_no_email(mock_request, mock_user_auth):
|
||||
"""
|
||||
GIVEN: User ID provided but email is None
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: 401 Unauthorized is raised
|
||||
"""
|
||||
# Arrange
|
||||
user_id = 'test-user-123'
|
||||
mock_user_auth.get_user_email.return_value = None
|
||||
|
||||
with patch('server.email_validation.get_user_auth', return_value=mock_user_auth):
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_admin_user_id(mock_request, user_id)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'email not available' in exc_info.value.detail.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_openhands_user_id_invalid_domain(mock_request, mock_user_auth):
|
||||
"""
|
||||
GIVEN: User ID and email with non-@openhands.dev domain
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: 403 Forbidden is raised
|
||||
"""
|
||||
# Arrange
|
||||
user_id = 'test-user-123'
|
||||
mock_user_auth.get_user_email.return_value = 'test@external.com'
|
||||
|
||||
with patch('server.email_validation.get_user_auth', return_value=mock_user_auth):
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_admin_user_id(mock_request, user_id)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert 'openhands.dev' in exc_info.value.detail.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_openhands_user_id_empty_string_user_id(mock_request):
|
||||
"""
|
||||
GIVEN: Empty string user ID
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: 401 Unauthorized is raised
|
||||
"""
|
||||
# Arrange
|
||||
user_id = ''
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_admin_user_id(mock_request, user_id)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'not authenticated' in exc_info.value.detail.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_openhands_user_id_case_sensitivity(mock_request, mock_user_auth):
|
||||
"""
|
||||
GIVEN: Email with uppercase @OPENHANDS.DEV domain
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: 403 Forbidden is raised (case-sensitive check)
|
||||
"""
|
||||
# Arrange
|
||||
user_id = 'test-user-123'
|
||||
mock_user_auth.get_user_email.return_value = 'test@OPENHANDS.DEV'
|
||||
|
||||
with patch('server.email_validation.get_user_auth', return_value=mock_user_auth):
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_admin_user_id(mock_request, user_id)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_openhands_user_id_subdomain_not_allowed(
|
||||
mock_request, mock_user_auth
|
||||
):
|
||||
"""
|
||||
GIVEN: Email with subdomain like @test.openhands.dev
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: 403 Forbidden is raised
|
||||
"""
|
||||
# Arrange
|
||||
user_id = 'test-user-123'
|
||||
mock_user_auth.get_user_email.return_value = 'test@test.openhands.dev'
|
||||
|
||||
with patch('server.email_validation.get_user_auth', return_value=mock_user_auth):
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_admin_user_id(mock_request, user_id)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_openhands_user_id_similar_domain_not_allowed(
|
||||
mock_request, mock_user_auth
|
||||
):
|
||||
"""
|
||||
GIVEN: Email with similar but different domain like @openhands.dev.fake.com
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: 403 Forbidden is raised
|
||||
"""
|
||||
# Arrange
|
||||
user_id = 'test-user-123'
|
||||
mock_user_auth.get_user_email.return_value = 'test@openhands.dev.fake.com'
|
||||
|
||||
with patch('server.email_validation.get_user_auth', return_value=mock_user_auth):
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_admin_user_id(mock_request, user_id)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_openhands_user_id_logs_warning_on_invalid_domain(
|
||||
mock_request, mock_user_auth
|
||||
):
|
||||
"""
|
||||
GIVEN: User with invalid email domain
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: Warning is logged with user_id and email_domain
|
||||
"""
|
||||
# Arrange
|
||||
user_id = 'test-user-123'
|
||||
invalid_email = 'test@external.com'
|
||||
mock_user_auth.get_user_email.return_value = invalid_email
|
||||
|
||||
with (
|
||||
patch('server.email_validation.get_user_auth', return_value=mock_user_auth),
|
||||
patch('server.email_validation.logger') as mock_logger,
|
||||
):
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException):
|
||||
await get_admin_user_id(mock_request, user_id)
|
||||
|
||||
# Verify warning was logged
|
||||
mock_logger.warning.assert_called_once()
|
||||
call_args = mock_logger.warning.call_args
|
||||
assert 'Access denied' in call_args[0][0]
|
||||
assert call_args[1]['extra']['user_id'] == user_id
|
||||
assert call_args[1]['extra']['email_domain'] == 'external.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_openhands_user_id_with_plus_addressing(mock_request, mock_user_auth):
|
||||
"""
|
||||
GIVEN: Email with plus addressing (test+tag@openhands.dev)
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: User ID is returned successfully
|
||||
"""
|
||||
# Arrange
|
||||
user_id = 'test-user-123'
|
||||
mock_user_auth.get_user_email.return_value = 'test+tag@openhands.dev'
|
||||
|
||||
with patch('server.email_validation.get_user_auth', return_value=mock_user_auth):
|
||||
# Act
|
||||
result = await get_admin_user_id(mock_request, user_id)
|
||||
|
||||
# Assert
|
||||
assert result == user_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_openhands_user_id_with_dots_in_local_part(
|
||||
mock_request, mock_user_auth
|
||||
):
|
||||
"""
|
||||
GIVEN: Email with dots in local part (first.last@openhands.dev)
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: User ID is returned successfully
|
||||
"""
|
||||
# Arrange
|
||||
user_id = 'test-user-123'
|
||||
mock_user_auth.get_user_email.return_value = 'first.last@openhands.dev'
|
||||
|
||||
with patch('server.email_validation.get_user_auth', return_value=mock_user_auth):
|
||||
# Act
|
||||
result = await get_admin_user_id(mock_request, user_id)
|
||||
|
||||
# Assert
|
||||
assert result == user_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_openhands_user_id_empty_email(mock_request, mock_user_auth):
|
||||
"""
|
||||
GIVEN: Empty string email
|
||||
WHEN: get_admin_user_id is called
|
||||
THEN: 401 Unauthorized is raised
|
||||
"""
|
||||
# Arrange
|
||||
user_id = 'test-user-123'
|
||||
mock_user_auth.get_user_email.return_value = ''
|
||||
|
||||
with patch('server.email_validation.get_user_auth', return_value=mock_user_auth):
|
||||
# Act & Assert
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_admin_user_id(mock_request, user_id)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert 'email not available' in exc_info.value.detail.lower()
|
||||
@@ -38,9 +38,8 @@ class TestDefaultInitialBudget:
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
# Clear the env vars
|
||||
# Clear the env var
|
||||
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
|
||||
os.environ.pop('ENABLE_BILLING', None)
|
||||
|
||||
# Restore original module or reimport fresh
|
||||
if original_module is not None:
|
||||
@@ -48,56 +47,31 @@ class TestDefaultInitialBudget:
|
||||
else:
|
||||
importlib.import_module('storage.lite_llm_manager')
|
||||
|
||||
def test_default_initial_budget_none_when_billing_disabled(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET is None when billing is disabled."""
|
||||
def test_default_initial_budget_defaults_to_zero(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET defaults to 0.0 when env var not set."""
|
||||
# Temporarily remove the module so we can reimport with different env vars
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
# Ensure billing is disabled (default) and reimport
|
||||
os.environ.pop('ENABLE_BILLING', None)
|
||||
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
|
||||
module = importlib.import_module('storage.lite_llm_manager')
|
||||
assert module.DEFAULT_INITIAL_BUDGET is None
|
||||
|
||||
def test_default_initial_budget_defaults_to_zero_when_billing_enabled(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET defaults to 0.0 when billing is enabled."""
|
||||
# Temporarily remove the module so we can reimport with different env vars
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
# Enable billing and reimport
|
||||
os.environ['ENABLE_BILLING'] = 'true'
|
||||
# Clear the env var and reimport
|
||||
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
|
||||
module = importlib.import_module('storage.lite_llm_manager')
|
||||
assert module.DEFAULT_INITIAL_BUDGET == 0.0
|
||||
|
||||
def test_default_initial_budget_uses_env_var_when_billing_enabled(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET uses value from environment variable when billing enabled."""
|
||||
def test_default_initial_budget_uses_env_var(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET uses value from environment variable."""
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ['ENABLE_BILLING'] = 'true'
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = '100.0'
|
||||
module = importlib.import_module('storage.lite_llm_manager')
|
||||
assert module.DEFAULT_INITIAL_BUDGET == 100.0
|
||||
|
||||
def test_default_initial_budget_ignores_env_var_when_billing_disabled(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET returns None when billing disabled, ignoring env var."""
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ.pop('ENABLE_BILLING', None) # billing disabled by default
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = '100.0'
|
||||
module = importlib.import_module('storage.lite_llm_manager')
|
||||
assert module.DEFAULT_INITIAL_BUDGET is None
|
||||
|
||||
def test_default_initial_budget_rejects_invalid_value(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET raises ValueError for invalid values."""
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ['ENABLE_BILLING'] = 'true'
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = 'abc'
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
importlib.import_module('storage.lite_llm_manager')
|
||||
@@ -108,7 +82,6 @@ class TestDefaultInitialBudget:
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ['ENABLE_BILLING'] = 'true'
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = '-10.0'
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
importlib.import_module('storage.lite_llm_manager')
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import time
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import jwt
|
||||
@@ -19,7 +18,6 @@ from server.auth.saas_user_auth import (
|
||||
saas_user_auth_from_cookie,
|
||||
saas_user_auth_from_signed_token,
|
||||
)
|
||||
from storage.api_key_store import ApiKeyValidationResult
|
||||
from storage.user_authorization import UserAuthorizationType
|
||||
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
@@ -470,22 +468,12 @@ async def test_saas_user_auth_from_bearer_success():
|
||||
algorithm='HS256',
|
||||
)
|
||||
|
||||
mock_org_id = uuid.uuid4()
|
||||
mock_validation_result = ApiKeyValidationResult(
|
||||
user_id='test_user_id',
|
||||
org_id=mock_org_id,
|
||||
key_id=42,
|
||||
key_name='Test Key',
|
||||
)
|
||||
|
||||
with (
|
||||
patch('server.auth.saas_user_auth.ApiKeyStore') as mock_api_key_store_cls,
|
||||
patch('server.auth.saas_user_auth.token_manager') as mock_token_manager,
|
||||
):
|
||||
mock_api_key_store = MagicMock()
|
||||
mock_api_key_store.validate_api_key = AsyncMock(
|
||||
return_value=mock_validation_result
|
||||
)
|
||||
mock_api_key_store.validate_api_key = AsyncMock(return_value='test_user_id')
|
||||
mock_api_key_store_cls.get_instance.return_value = mock_api_key_store
|
||||
|
||||
mock_token_manager.load_offline_token = AsyncMock(return_value=offline_token)
|
||||
@@ -497,9 +485,6 @@ async def test_saas_user_auth_from_bearer_success():
|
||||
|
||||
assert isinstance(result, SaasUserAuth)
|
||||
assert result.user_id == 'test_user_id'
|
||||
assert result.api_key_org_id == mock_org_id
|
||||
assert result.api_key_id == 42
|
||||
assert result.api_key_name == 'Test Key'
|
||||
mock_api_key_store.validate_api_key.assert_called_once_with('test_api_key')
|
||||
mock_token_manager.load_offline_token.assert_called_once_with('test_user_id')
|
||||
mock_token_manager.refresh.assert_called_once_with(offline_token)
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ContextMenuContainer } from "#/components/features/context-menu/context-menu-container";
|
||||
|
||||
describe("ContextMenuContainer", () => {
|
||||
const user = userEvent.setup();
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
it("should render children", () => {
|
||||
render(
|
||||
<ContextMenuContainer onClose={onCloseMock}>
|
||||
<div data-testid="child-1">Child 1</div>
|
||||
<div data-testid="child-2">Child 2</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("child-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply consistent base styling", () => {
|
||||
render(
|
||||
<ContextMenuContainer onClose={onCloseMock} testId="test-container">
|
||||
<div>Content</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
const container = screen.getByTestId("test-container");
|
||||
expect(container).toHaveClass("bg-[#050505]");
|
||||
expect(container).toHaveClass("border");
|
||||
expect(container).toHaveClass("border-[#242424]");
|
||||
expect(container).toHaveClass("rounded-[12px]");
|
||||
expect(container).toHaveClass("p-[25px]");
|
||||
expect(container).toHaveClass("context-menu-box-shadow");
|
||||
});
|
||||
|
||||
it("should call onClose when clicking outside", async () => {
|
||||
render(
|
||||
<ContextMenuContainer onClose={onCloseMock} testId="test-container">
|
||||
<div>Content</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
await user.click(document.body);
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should render children in a flex row layout", () => {
|
||||
render(
|
||||
<ContextMenuContainer onClose={onCloseMock} testId="test-container">
|
||||
<div data-testid="child-1">Child 1</div>
|
||||
<div data-testid="child-2">Child 2</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
const container = screen.getByTestId("test-container");
|
||||
const innerDiv = container.firstChild as HTMLElement;
|
||||
expect(innerDiv).toHaveClass("flex");
|
||||
expect(innerDiv).toHaveClass("flex-row");
|
||||
expect(innerDiv).toHaveClass("gap-4");
|
||||
});
|
||||
|
||||
it("should apply additional className when provided", () => {
|
||||
render(
|
||||
<ContextMenuContainer
|
||||
onClose={onCloseMock}
|
||||
testId="test-container"
|
||||
className="custom-class"
|
||||
>
|
||||
<div>Content</div>
|
||||
</ContextMenuContainer>,
|
||||
);
|
||||
|
||||
const container = screen.getByTestId("test-container");
|
||||
expect(container).toHaveClass("custom-class");
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ContextMenuCTA } from "#/components/features/context-menu/context-menu-cta";
|
||||
|
||||
// Mock useTracking hook
|
||||
const mockTrackSaasSelfhostedInquiry = vi.fn();
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ContextMenuCTA", () => {
|
||||
it("should render the CTA component", () => {
|
||||
render(<ContextMenuCTA />);
|
||||
|
||||
expect(screen.getByText("CTA$ENTERPRISE_TITLE")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$ENTERPRISE_DESCRIPTION")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$LEARN_MORE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call trackSaasSelfhostedInquiry with location 'context_menu' when Learn More is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ContextMenuCTA />);
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
await user.click(learnMoreLink);
|
||||
|
||||
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
|
||||
location: "context_menu",
|
||||
});
|
||||
});
|
||||
|
||||
it("should render Learn More as a link with correct href and target", () => {
|
||||
render(<ContextMenuCTA />);
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://openhands.dev/enterprise/",
|
||||
);
|
||||
expect(learnMoreLink).toHaveAttribute("target", "_blank");
|
||||
expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should render the stacked icon", () => {
|
||||
render(<ContextMenuCTA />);
|
||||
|
||||
const contentContainer = screen.getByTestId("context-menu-cta-content");
|
||||
const icon = contentContainer.querySelector("svg");
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveAttribute("width", "40");
|
||||
expect(icon).toHaveAttribute("height", "40");
|
||||
});
|
||||
});
|
||||
@@ -49,17 +49,9 @@ vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displayErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock feature flags - we'll control the return value in each test
|
||||
const mockEnableProjUserJourney = vi.fn(() => true);
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
ENABLE_PROJ_USER_JOURNEY: () => mockEnableProjUserJourney(),
|
||||
}));
|
||||
|
||||
describe("LoginContent", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", { href: "" });
|
||||
// Reset mock to return true by default
|
||||
mockEnableProjUserJourney.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -282,65 +274,6 @@ describe("LoginContent", () => {
|
||||
expect(screen.getByTestId("terms-and-privacy-notice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the enterprise LoginCTA component when appMode is saas and feature flag enabled", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("login-cta")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display the enterprise LoginCTA component when appMode is oss even with feature flag enabled", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="oss"
|
||||
providersConfigured={["github"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display the enterprise LoginCTA component when appMode is null", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode={null}
|
||||
providersConfigured={["github"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display the enterprise LoginCTA component when feature flag is disabled", () => {
|
||||
// Disable the feature flag
|
||||
mockEnableProjUserJourney.mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display invitation pending message when hasInvitation is true", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LoginCTA } from "#/components/features/auth/login-cta";
|
||||
|
||||
// Mock useTracking hook
|
||||
const mockTrackSaasSelfhostedInquiry = vi.fn();
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("LoginCTA", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render enterprise CTA with title and description", () => {
|
||||
render(<LoginCTA />);
|
||||
|
||||
expect(screen.getByTestId("login-cta")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$ENTERPRISE")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$ENTERPRISE_DEPLOY")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all enterprise feature list items", () => {
|
||||
render(<LoginCTA />);
|
||||
|
||||
expect(screen.getByText("CTA$FEATURE_ON_PREMISES")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$FEATURE_DATA_CONTROL")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$FEATURE_COMPLIANCE")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$FEATURE_SUPPORT")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render Learn More as a link with correct href and target", () => {
|
||||
render(<LoginCTA />);
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://openhands.dev/enterprise/",
|
||||
);
|
||||
expect(learnMoreLink).toHaveAttribute("target", "_blank");
|
||||
expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should call trackSaasSelfhostedInquiry with location 'login_page' when Learn More is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginCTA />);
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
await user.click(learnMoreLink);
|
||||
|
||||
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
|
||||
location: "login_page",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,23 +11,23 @@ vi.mock("posthog-js/react", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { ENABLE_PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
|
||||
const { PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
|
||||
PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
ENABLE_PROJ_USER_JOURNEY: () => ENABLE_PROJ_USER_JOURNEY_MOCK(),
|
||||
PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(),
|
||||
}));
|
||||
|
||||
describe("EnterpriseBanner", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("Feature Flag", () => {
|
||||
it("should not render when proj_user_journey feature flag is disabled", () => {
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
|
||||
|
||||
const { container } = renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("EnterpriseBanner", () => {
|
||||
});
|
||||
|
||||
it("should render when proj_user_journey feature flag is enabled", () => {
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { HomepageCTA } from "#/components/features/home/homepage-cta";
|
||||
|
||||
// Mock the translation function
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"CTA$ENTERPRISE_TITLE": "Get OpenHands for Enterprise",
|
||||
"CTA$ENTERPRISE_DESCRIPTION":
|
||||
"Cloud allows you to access OpenHands anywhere and coordinate with your team like never before",
|
||||
"CTA$LEARN_MORE": "Learn More",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock local storage
|
||||
vi.mock("#/utils/local-storage", () => ({
|
||||
setCTADismissed: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock useTracking hook
|
||||
const mockTrackSaasSelfhostedInquiry = vi.fn();
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { setCTADismissed } from "#/utils/local-storage";
|
||||
|
||||
describe("HomepageCTA", () => {
|
||||
const mockSetShouldShowCTA = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderHomepageCTA = () => {
|
||||
return render(<HomepageCTA setShouldShowCTA={mockSetShouldShowCTA} />);
|
||||
};
|
||||
|
||||
describe("rendering", () => {
|
||||
it("renders the enterprise title", () => {
|
||||
renderHomepageCTA();
|
||||
expect(
|
||||
screen.getByText("Get OpenHands for Enterprise"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the enterprise description", () => {
|
||||
renderHomepageCTA();
|
||||
expect(
|
||||
screen.getByText(/Cloud allows you to access OpenHands anywhere/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Learn More link", () => {
|
||||
renderHomepageCTA();
|
||||
const link = screen.getByRole("link", { name: "Learn More" });
|
||||
expect(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the close button with correct aria-label", () => {
|
||||
renderHomepageCTA();
|
||||
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("close button behavior", () => {
|
||||
it("calls setCTADismissed with 'homepage' when close button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHomepageCTA();
|
||||
|
||||
const closeButton = screen.getByRole("button", { name: "Close" });
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(setCTADismissed).toHaveBeenCalledWith("homepage");
|
||||
});
|
||||
|
||||
it("calls setShouldShowCTA with false when close button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHomepageCTA();
|
||||
|
||||
const closeButton = screen.getByRole("button", { name: "Close" });
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(mockSetShouldShowCTA).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("calls both setCTADismissed and setShouldShowCTA in order", async () => {
|
||||
const user = userEvent.setup();
|
||||
const callOrder: string[] = [];
|
||||
|
||||
vi.mocked(setCTADismissed).mockImplementation(() => {
|
||||
callOrder.push("setCTADismissed");
|
||||
});
|
||||
mockSetShouldShowCTA.mockImplementation(() => {
|
||||
callOrder.push("setShouldShowCTA");
|
||||
});
|
||||
|
||||
renderHomepageCTA();
|
||||
|
||||
const closeButton = screen.getByRole("button", { name: "Close" });
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(callOrder).toEqual(["setCTADismissed", "setShouldShowCTA"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Learn More link behavior", () => {
|
||||
it("calls trackSaasSelfhostedInquiry with location 'home_page' when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHomepageCTA();
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", { name: "Learn More" });
|
||||
await user.click(learnMoreLink);
|
||||
|
||||
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
|
||||
location: "home_page",
|
||||
});
|
||||
});
|
||||
|
||||
it("has correct href and target attributes", () => {
|
||||
renderHomepageCTA();
|
||||
|
||||
const learnMoreLink = screen.getByRole("link", { name: "Learn More" });
|
||||
expect(learnMoreLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://openhands.dev/enterprise/",
|
||||
);
|
||||
expect(learnMoreLink).toHaveAttribute("target", "_blank");
|
||||
expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("accessibility", () => {
|
||||
it("close button is focusable", () => {
|
||||
renderHomepageCTA();
|
||||
const closeButton = screen.getByRole("button", { name: "Close" });
|
||||
expect(closeButton).not.toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
|
||||
it("Learn More link is focusable", () => {
|
||||
renderHomepageCTA();
|
||||
const learnMoreLink = screen.getByRole("link", { name: "Learn More" });
|
||||
expect(learnMoreLink).not.toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,6 @@ import OnboardingForm from "#/routes/onboarding-form";
|
||||
|
||||
const mockMutate = vi.fn();
|
||||
const mockNavigate = vi.fn();
|
||||
const mockUseConfig = vi.fn();
|
||||
const mockTrackOnboardingCompleted = vi.fn();
|
||||
|
||||
vi.mock("react-router", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("react-router")>();
|
||||
@@ -24,16 +22,6 @@ vi.mock("#/hooks/mutation/use-submit-onboarding", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => mockUseConfig(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackOnboardingCompleted: mockTrackOnboardingCompleted,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderOnboardingForm = () => {
|
||||
return renderWithProviders(
|
||||
<MemoryRouter>
|
||||
@@ -42,15 +30,10 @@ const renderOnboardingForm = () => {
|
||||
);
|
||||
};
|
||||
|
||||
describe("OnboardingForm - SaaS Mode", () => {
|
||||
describe("OnboardingForm", () => {
|
||||
beforeEach(() => {
|
||||
mockMutate.mockClear();
|
||||
mockNavigate.mockClear();
|
||||
mockTrackOnboardingCompleted.mockClear();
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render with the correct test id", () => {
|
||||
@@ -67,7 +50,7 @@ describe("OnboardingForm - SaaS Mode", () => {
|
||||
expect(screen.getByTestId("step-actions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display step progress indicator with 3 bars for saas mode", () => {
|
||||
it("should display step progress indicator with 3 bars", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
@@ -86,7 +69,7 @@ describe("OnboardingForm - SaaS Mode", () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
@@ -101,7 +84,7 @@ describe("OnboardingForm - SaaS Mode", () => {
|
||||
let progressBars = stepHeader.querySelectorAll(".bg-white");
|
||||
expect(progressBars).toHaveLength(1);
|
||||
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// On step 2, first two progress bars should be filled
|
||||
@@ -113,7 +96,7 @@ describe("OnboardingForm - SaaS Mode", () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
@@ -124,51 +107,29 @@ describe("OnboardingForm - SaaS Mode", () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Step 1 - select org size (first step in saas mode - single select)
|
||||
// Step 1 - select role
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 2 - select org size
|
||||
await user.click(screen.getByTestId("step-option-org_2_10"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 2 - select use case (multi-select)
|
||||
// Step 3 - select use case
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 3 - select role (last step in saas mode - single select)
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
selections: {
|
||||
org_size: "org_2_10",
|
||||
use_case: ["new_features"],
|
||||
role: "software_engineer",
|
||||
step1: "software_engineer",
|
||||
step2: "org_2_10",
|
||||
step3: "new_features",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should track onboarding completion to PostHog in SaaS mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Complete the full SaaS onboarding flow
|
||||
await user.click(screen.getByTestId("step-option-org_2_10"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
expect(mockTrackOnboardingCompleted).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrackOnboardingCompleted).toHaveBeenCalledWith({
|
||||
role: "software_engineer",
|
||||
orgSize: "org_2_10",
|
||||
useCase: ["new_features"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should render 5 options on step 1 (org size question)", () => {
|
||||
it("should render 6 options on step 1", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
const options = screen
|
||||
@@ -176,86 +137,31 @@ describe("OnboardingForm - SaaS Mode", () => {
|
||||
.filter((btn) =>
|
||||
btn.getAttribute("data-testid")?.startsWith("step-option-"),
|
||||
);
|
||||
expect(options).toHaveLength(5);
|
||||
expect(options).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("should preserve selections when navigating through steps", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Select org size on step 1 (single select)
|
||||
// Select role on step 1
|
||||
await user.click(screen.getByTestId("step-option-cto_founder"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Select org size on step 2
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Select use case on step 2 (multi-select)
|
||||
// Select use case on step 3
|
||||
await user.click(screen.getByTestId("step-option-fixing_bugs"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Select role on step 3 (single select)
|
||||
await user.click(screen.getByTestId("step-option-cto_founder"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
// Verify all selections were preserved
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
selections: {
|
||||
org_size: "solo",
|
||||
use_case: ["fixing_bugs"],
|
||||
role: "cto_founder",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow selecting multiple options on multi-select steps", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Step 1 - select org size (single select)
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 2 - select multiple use cases (multi-select)
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByTestId("step-option-fixing_bugs"));
|
||||
await user.click(screen.getByTestId("step-option-refactoring"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 3 - select role (single select)
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
selections: {
|
||||
org_size: "solo",
|
||||
use_case: ["new_features", "fixing_bugs", "refactoring"],
|
||||
role: "software_engineer",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow deselecting options on multi-select steps", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Step 1 - select org size
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 2 - select and deselect use cases
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByTestId("step-option-fixing_bugs"));
|
||||
await user.click(screen.getByTestId("step-option-new_features")); // Deselect
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 3 - select role
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
selections: {
|
||||
org_size: "solo",
|
||||
use_case: ["fixing_bugs"],
|
||||
role: "software_engineer",
|
||||
step1: "cto_founder",
|
||||
step2: "solo",
|
||||
step3: "fixing_bugs",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -265,10 +171,10 @@ describe("OnboardingForm - SaaS Mode", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
// Navigate to step 3
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// On step 3, all three progress bars should be filled
|
||||
@@ -288,7 +194,7 @@ describe("OnboardingForm - SaaS Mode", () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
const backButton = screen.getByRole("button", { name: /back/i });
|
||||
@@ -300,7 +206,7 @@ describe("OnboardingForm - SaaS Mode", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
// Navigate to step 2
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Verify we're on step 2 (2 progress bars filled)
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("StepContent", () => {
|
||||
|
||||
const defaultProps = {
|
||||
options: mockOptions,
|
||||
selectedOptionIds: [],
|
||||
selectedOptionId: null,
|
||||
onSelectOption: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("StepContent", () => {
|
||||
});
|
||||
|
||||
it("should mark the selected option as selected", () => {
|
||||
render(<StepContent {...defaultProps} selectedOptionIds={["option1"]} />);
|
||||
render(<StepContent {...defaultProps} selectedOptionId="option1" />);
|
||||
|
||||
const selectedOption = screen.getByTestId("step-option-option1");
|
||||
const unselectedOption = screen.getByTestId("step-option-option2");
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { StepInput } from "#/components/features/onboarding/step-input";
|
||||
|
||||
describe("StepInput", () => {
|
||||
const defaultProps = {
|
||||
id: "test-input",
|
||||
label: "Test Label",
|
||||
value: "",
|
||||
onChange: vi.fn(),
|
||||
};
|
||||
|
||||
it("should render with correct test id", () => {
|
||||
render(<StepInput {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("step-input-test-input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the label", () => {
|
||||
render(<StepInput {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Test Label")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the provided value", () => {
|
||||
render(<StepInput {...defaultProps} value="Hello World" />);
|
||||
|
||||
const input = screen.getByTestId("step-input-test-input");
|
||||
expect(input).toHaveValue("Hello World");
|
||||
});
|
||||
|
||||
it("should call onChange when user types", async () => {
|
||||
const mockOnChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<StepInput {...defaultProps} onChange={mockOnChange} />);
|
||||
|
||||
const input = screen.getByTestId("step-input-test-input");
|
||||
await user.type(input, "a");
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith("a");
|
||||
});
|
||||
|
||||
it("should call onChange with the full input value on each keystroke", async () => {
|
||||
const mockOnChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<StepInput {...defaultProps} onChange={mockOnChange} />);
|
||||
|
||||
const input = screen.getByTestId("step-input-test-input");
|
||||
await user.type(input, "abc");
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledTimes(3);
|
||||
expect(mockOnChange).toHaveBeenNthCalledWith(1, "a");
|
||||
expect(mockOnChange).toHaveBeenNthCalledWith(2, "b");
|
||||
expect(mockOnChange).toHaveBeenNthCalledWith(3, "c");
|
||||
});
|
||||
|
||||
it("should use the id prop for data-testid", () => {
|
||||
render(<StepInput {...defaultProps} id="org_name" />);
|
||||
|
||||
expect(screen.getByTestId("step-input-org_name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render as a text input", () => {
|
||||
render(<StepInput {...defaultProps} />);
|
||||
|
||||
const input = screen.getByTestId("step-input-test-input");
|
||||
expect(input).toHaveAttribute("type", "text");
|
||||
});
|
||||
});
|
||||
@@ -18,27 +18,6 @@ import { OrganizationMember } from "#/types/org";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
|
||||
|
||||
// Mock useBreakpoint hook
|
||||
vi.mock("#/hooks/use-breakpoint", () => ({
|
||||
useBreakpoint: vi.fn(() => false), // Default to desktop (not mobile)
|
||||
}));
|
||||
|
||||
// Mock feature flags
|
||||
const mockEnableProjUserJourney = vi.fn(() => true);
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
ENABLE_PROJ_USER_JOURNEY: () => mockEnableProjUserJourney(),
|
||||
}));
|
||||
|
||||
// Mock useTracking hook for CTA
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackSaasSelfhostedInquiry: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Import the mocked modules
|
||||
import * as breakpoint from "#/hooks/use-breakpoint";
|
||||
|
||||
type UserContextMenuProps = GetComponentPropTypes<typeof UserContextMenu>;
|
||||
|
||||
function UserContextMenuWithRootOutlet({
|
||||
@@ -144,9 +123,6 @@ describe("UserContextMenu", () => {
|
||||
// Ensure clean state at the start of each test
|
||||
vi.restoreAllMocks();
|
||||
useSelectedOrganizationStore.setState({ organizationId: null });
|
||||
// Reset feature flag and breakpoint mocks to defaults
|
||||
mockEnableProjUserJourney.mockReturnValue(true);
|
||||
vi.mocked(breakpoint.useBreakpoint).mockReturnValue(false); // Desktop by default
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -654,77 +630,4 @@ describe("UserContextMenu", () => {
|
||||
// Verify that the dropdown shows the selected organization
|
||||
expect(screen.getByRole("combobox")).toHaveValue(INITIAL_MOCK_ORGS[1].name);
|
||||
});
|
||||
|
||||
describe("Context Menu CTA", () => {
|
||||
it("should render the CTA component in SaaS mode on desktop with feature flag enabled", async () => {
|
||||
// Set SaaS mode
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({ app_mode: "saas" }),
|
||||
);
|
||||
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for config to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("context-menu-cta")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("CTA$ENTERPRISE_TITLE")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA$LEARN_MORE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render the CTA component in OSS mode even with feature flag enabled", async () => {
|
||||
// Set OSS mode
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({ app_mode: "oss" }),
|
||||
);
|
||||
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for config to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render the CTA component on mobile even in SaaS mode with feature flag enabled", async () => {
|
||||
// Set SaaS mode
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({ app_mode: "saas" }),
|
||||
);
|
||||
// Set mobile mode
|
||||
vi.mocked(breakpoint.useBreakpoint).mockReturnValue(true);
|
||||
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for config to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render the CTA component when feature flag is disabled in SaaS mode", async () => {
|
||||
// Set SaaS mode
|
||||
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
|
||||
createMockWebClientConfig({ app_mode: "saas" }),
|
||||
);
|
||||
// Disable the feature flag
|
||||
mockEnableProjUserJourney.mockReturnValue(false);
|
||||
|
||||
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
|
||||
|
||||
// Wait for config to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getEventContent } from "#/components/v1/chat";
|
||||
import { ActionEvent, ObservationEvent, SecurityRisk } from "#/types/v1/core";
|
||||
|
||||
const terminalActionEvent: ActionEvent = {
|
||||
id: "action-1",
|
||||
timestamp: new Date().toISOString(),
|
||||
source: "agent",
|
||||
thought: [{ type: "text", text: "Checking repository status." }],
|
||||
thinking_blocks: [],
|
||||
action: {
|
||||
kind: "TerminalAction",
|
||||
command: "git status",
|
||||
is_input: false,
|
||||
timeout: null,
|
||||
reset: false,
|
||||
},
|
||||
tool_name: "terminal",
|
||||
tool_call_id: "tool-1",
|
||||
tool_call: {
|
||||
id: "tool-1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "terminal",
|
||||
arguments: '{"command":"git status"}',
|
||||
},
|
||||
},
|
||||
llm_response_id: "response-1",
|
||||
security_risk: SecurityRisk.LOW,
|
||||
summary: "Check repository status",
|
||||
};
|
||||
|
||||
const terminalObservationEvent: ObservationEvent = {
|
||||
id: "obs-1",
|
||||
timestamp: new Date().toISOString(),
|
||||
source: "environment",
|
||||
tool_name: "terminal",
|
||||
tool_call_id: "tool-1",
|
||||
action_id: "action-1",
|
||||
observation: {
|
||||
kind: "TerminalObservation",
|
||||
content: [{ type: "text", text: "On branch main" }],
|
||||
command: "git status",
|
||||
exit_code: 0,
|
||||
is_error: false,
|
||||
timeout: false,
|
||||
metadata: {
|
||||
exit_code: 0,
|
||||
pid: 1,
|
||||
username: "openhands",
|
||||
hostname: "sandbox",
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
working_dir: "/workspace/project/OpenHands",
|
||||
py_interpreter_path: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("getEventContent", () => {
|
||||
it("uses the action summary as the full action title", () => {
|
||||
const { title } = getEventContent(terminalActionEvent);
|
||||
|
||||
render(<>{title}</>);
|
||||
|
||||
expect(screen.getByText("Check repository status")).toBeInTheDocument();
|
||||
expect(screen.queryByText("$ git status")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to command-based title when summary is missing", () => {
|
||||
const actionWithoutSummary = { ...terminalActionEvent, summary: undefined };
|
||||
const { title } = getEventContent(actionWithoutSummary);
|
||||
|
||||
render(<>{title}</>);
|
||||
|
||||
// Without i18n loaded, the translation key renders as the raw key
|
||||
expect(screen.getByText("ACTION_MESSAGE$RUN")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Check repository status"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reuses the action summary as the full paired observation title", () => {
|
||||
const { title } = getEventContent(
|
||||
terminalObservationEvent,
|
||||
terminalActionEvent,
|
||||
);
|
||||
|
||||
render(<>{title}</>);
|
||||
|
||||
expect(screen.getByText("Check repository status")).toBeInTheDocument();
|
||||
expect(screen.queryByText("$ git status")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,6 @@ describe("Changes Tab", () => {
|
||||
vi.mocked(useUnifiedGetGitChanges).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
@@ -51,7 +50,6 @@ describe("Changes Tab", () => {
|
||||
vi.mocked(useUnifiedGetGitChanges).mockReturnValue({
|
||||
data: [{ path: "src/file.ts", status: "M" }],
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: null,
|
||||
|
||||
@@ -5,12 +5,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import DeviceVerify from "#/routes/device-verify";
|
||||
|
||||
const { useIsAuthedMock, ENABLE_PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
|
||||
const { useIsAuthedMock, PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
|
||||
useIsAuthedMock: vi.fn(() => ({
|
||||
data: false as boolean | undefined,
|
||||
isLoading: false,
|
||||
})),
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
|
||||
PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
@@ -24,7 +24,7 @@ vi.mock("posthog-js/react", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
ENABLE_PROJ_USER_JOURNEY: () => ENABLE_PROJ_USER_JOURNEY_MOCK(),
|
||||
PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(),
|
||||
}));
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
@@ -67,7 +67,7 @@ describe("DeviceVerify", () => {
|
||||
),
|
||||
);
|
||||
// Enable feature flag by default
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -254,7 +254,7 @@ describe("DeviceVerify", () => {
|
||||
});
|
||||
|
||||
it("should not include the EnterpriseBanner and be center-aligned when feature flag is disabled", async () => {
|
||||
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
|
||||
@@ -609,193 +609,3 @@ describe("New user welcome toast", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HomepageCTA visibility", () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.spyOn(AuthService, "authenticate").mockResolvedValue(true);
|
||||
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
// Mock localStorage to enable the PROJ_USER_JOURNEY feature flag (CTA dismissal also uses localStorage)
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => {
|
||||
if (key === "FEATURE_PROJ_USER_JOURNEY") {
|
||||
return "true";
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("should show HomepageCTA in SaaS mode when not dismissed and feature flag enabled", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
});
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
app_mode: "saas",
|
||||
posthog_client_key: "test-posthog-key",
|
||||
providers_configured: ["github"],
|
||||
auth_url: "https://auth.example.com",
|
||||
feature_flags: DEFAULT_FEATURE_FLAGS,
|
||||
maintenance_start_time: null,
|
||||
recaptcha_site_key: null,
|
||||
faulty_models: [],
|
||||
error_message: null,
|
||||
updated_at: "2024-01-14T10:00:00Z",
|
||||
github_app_slug: null,
|
||||
});
|
||||
|
||||
renderHomeScreen();
|
||||
|
||||
await screen.findByTestId("home-screen");
|
||||
|
||||
const ctaLink = await screen.findByRole("link", {
|
||||
name: "CTA$LEARN_MORE",
|
||||
});
|
||||
expect(ctaLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show HomepageCTA in OSS mode even with feature flag enabled", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
});
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { app_mode: "oss", feature_flags: DEFAULT_FEATURE_FLAGS },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
app_mode: "oss",
|
||||
posthog_client_key: "test-posthog-key",
|
||||
providers_configured: ["github"],
|
||||
auth_url: "https://auth.example.com",
|
||||
feature_flags: DEFAULT_FEATURE_FLAGS,
|
||||
maintenance_start_time: null,
|
||||
recaptcha_site_key: null,
|
||||
faulty_models: [],
|
||||
error_message: null,
|
||||
updated_at: "2024-01-14T10:00:00Z",
|
||||
github_app_slug: null,
|
||||
});
|
||||
|
||||
renderHomeScreen();
|
||||
|
||||
await screen.findByTestId("home-screen");
|
||||
|
||||
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show HomepageCTA when feature flag is disabled", async () => {
|
||||
// Override localStorage to disable the feature flag
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn(() => null), // No feature flags set
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
});
|
||||
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
});
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
app_mode: "saas",
|
||||
posthog_client_key: "test-posthog-key",
|
||||
providers_configured: ["github"],
|
||||
auth_url: "https://auth.example.com",
|
||||
feature_flags: DEFAULT_FEATURE_FLAGS,
|
||||
maintenance_start_time: null,
|
||||
recaptcha_site_key: null,
|
||||
faulty_models: [],
|
||||
error_message: null,
|
||||
updated_at: "2024-01-14T10:00:00Z",
|
||||
github_app_slug: null,
|
||||
});
|
||||
|
||||
renderHomeScreen();
|
||||
|
||||
await screen.findByTestId("home-screen");
|
||||
|
||||
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show HomepageCTA when dismissed in local storage", async () => {
|
||||
// Override localStorage to mark CTA as dismissed while keeping the feature flag enabled
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => {
|
||||
if (key === "FEATURE_PROJ_USER_JOURNEY") {
|
||||
return "true";
|
||||
}
|
||||
if (key === "homepage-cta-dismissed") {
|
||||
return "true";
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
});
|
||||
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
});
|
||||
useConfigMock.mockReturnValue({
|
||||
data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
app_mode: "saas",
|
||||
posthog_client_key: "test-posthog-key",
|
||||
providers_configured: ["github"],
|
||||
auth_url: "https://auth.example.com",
|
||||
feature_flags: DEFAULT_FEATURE_FLAGS,
|
||||
maintenance_start_time: null,
|
||||
recaptcha_site_key: null,
|
||||
faulty_models: [],
|
||||
error_message: null,
|
||||
updated_at: "2024-01-14T10:00:00Z",
|
||||
github_app_slug: null,
|
||||
});
|
||||
|
||||
renderHomeScreen();
|
||||
|
||||
await screen.findByTestId("home-screen");
|
||||
|
||||
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,39 +13,7 @@ import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
|
||||
import type { Organization, OrganizationMember } from "#/types/org";
|
||||
|
||||
/** Creates a mock Organization with default values for testing */
|
||||
const createMockOrganization = (
|
||||
overrides: Partial<Organization> & Pick<Organization, "id" | "name">,
|
||||
): Organization => ({
|
||||
contact_name: "",
|
||||
contact_email: "",
|
||||
conversation_expiration: 0,
|
||||
agent: "CodeActAgent",
|
||||
default_max_iterations: 20,
|
||||
security_analyzer: "",
|
||||
confirmation_mode: false,
|
||||
default_llm_model: "",
|
||||
default_llm_api_key_for_byor: "",
|
||||
default_llm_base_url: "",
|
||||
remote_runtime_resource_factor: 1,
|
||||
enable_default_condenser: true,
|
||||
billing_margin: 0,
|
||||
enable_proactive_conversation_starters: false,
|
||||
sandbox_base_container_image: "",
|
||||
sandbox_runtime_container_image: "",
|
||||
org_version: 1,
|
||||
mcp_config: { tools: [], settings: {} },
|
||||
search_api_key: null,
|
||||
sandbox_api_key: null,
|
||||
max_budget_per_task: 0,
|
||||
enable_solvability_analysis: false,
|
||||
v1_enabled: true,
|
||||
credits: 0,
|
||||
is_personal: false,
|
||||
...overrides,
|
||||
});
|
||||
import type { OrganizationMember } from "#/types/org";
|
||||
|
||||
// Mock react-router hooks
|
||||
const mockUseSearchParams = vi.fn();
|
||||
@@ -1799,163 +1767,3 @@ describe("clientLoader permission checks", () => {
|
||||
expect(typeof clientLoader).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Contextual info messages", () => {
|
||||
it("should show admin message when user is an admin in a team organization", async () => {
|
||||
// Arrange
|
||||
const orgId = "team-org-1";
|
||||
const adminMeData: OrganizationMember = {
|
||||
org_id: orgId,
|
||||
user_id: "1",
|
||||
email: "admin@example.com",
|
||||
role: "admin",
|
||||
status: "active",
|
||||
llm_api_key: "",
|
||||
max_iterations: 20,
|
||||
llm_model: "",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
};
|
||||
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(adminMeData);
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [
|
||||
createMockOrganization({
|
||||
id: orgId,
|
||||
name: "Team Org",
|
||||
is_personal: false,
|
||||
}),
|
||||
],
|
||||
currentOrgId: orgId,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderLlmSettingsScreen(orgId, adminMeData);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("llm-settings-info-message"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("llm-settings-info-message")).toHaveTextContent(
|
||||
"SETTINGS$LLM_ADMIN_INFO",
|
||||
);
|
||||
});
|
||||
|
||||
it("should show member message when user is a member in a team organization", async () => {
|
||||
// Arrange
|
||||
const orgId = "team-org-2";
|
||||
const memberMeData: OrganizationMember = {
|
||||
org_id: orgId,
|
||||
user_id: "2",
|
||||
email: "member@example.com",
|
||||
role: "member",
|
||||
status: "active",
|
||||
llm_api_key: "",
|
||||
max_iterations: 20,
|
||||
llm_model: "",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
};
|
||||
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(memberMeData);
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [
|
||||
createMockOrganization({
|
||||
id: orgId,
|
||||
name: "Team Org",
|
||||
is_personal: false,
|
||||
}),
|
||||
],
|
||||
currentOrgId: orgId,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderLlmSettingsScreen(orgId, memberMeData);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("llm-settings-info-message"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("llm-settings-info-message")).toHaveTextContent(
|
||||
"SETTINGS$LLM_MEMBER_INFO",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not show info message in personal workspace", async () => {
|
||||
// Arrange
|
||||
const orgId = "personal-org-1";
|
||||
const ownerMeData: OrganizationMember = {
|
||||
org_id: orgId,
|
||||
user_id: "3",
|
||||
email: "user@example.com",
|
||||
role: "owner",
|
||||
status: "active",
|
||||
llm_api_key: "",
|
||||
max_iterations: 20,
|
||||
llm_model: "",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
};
|
||||
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "saas" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
vi.spyOn(organizationService, "getMe").mockResolvedValue(ownerMeData);
|
||||
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
|
||||
items: [
|
||||
createMockOrganization({ id: orgId, name: "Personal", is_personal: true }),
|
||||
],
|
||||
currentOrgId: orgId,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderLlmSettingsScreen(orgId, ownerMeData);
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("llm-settings-screen")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("llm-settings-info-message"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show info message in OSS mode", async () => {
|
||||
// Arrange
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: { app_mode: "oss" },
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderLlmSettingsScreen();
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("llm-settings-screen")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("llm-settings-info-message"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,11 +73,6 @@ vi.mock("#/hooks/use-invitation", () => ({
|
||||
useInvitation: () => useInvitationMock(),
|
||||
}));
|
||||
|
||||
// Mock feature flags - enable by default for tests
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
ENABLE_PROJ_USER_JOURNEY: () => true,
|
||||
}));
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: LoginPage,
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import {
|
||||
LOCAL_STORAGE_KEYS,
|
||||
LoginMethod,
|
||||
setLoginMethod,
|
||||
getLoginMethod,
|
||||
clearLoginData,
|
||||
setCTADismissed,
|
||||
isCTADismissed,
|
||||
} from "#/utils/local-storage";
|
||||
|
||||
describe("local-storage utilities", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("Login method utilities", () => {
|
||||
describe("setLoginMethod", () => {
|
||||
it("stores the login method in local storage", () => {
|
||||
setLoginMethod(LoginMethod.GITHUB);
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("github");
|
||||
});
|
||||
|
||||
it("stores different login methods correctly", () => {
|
||||
setLoginMethod(LoginMethod.GITLAB);
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("gitlab");
|
||||
|
||||
setLoginMethod(LoginMethod.BITBUCKET);
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("bitbucket");
|
||||
|
||||
setLoginMethod(LoginMethod.AZURE_DEVOPS);
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("azure_devops");
|
||||
|
||||
setLoginMethod(LoginMethod.ENTERPRISE_SSO);
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("enterprise_sso");
|
||||
|
||||
setLoginMethod(LoginMethod.BITBUCKET_DATA_CENTER);
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("bitbucket_data_center");
|
||||
});
|
||||
|
||||
it("overwrites previous login method", () => {
|
||||
setLoginMethod(LoginMethod.GITHUB);
|
||||
setLoginMethod(LoginMethod.GITLAB);
|
||||
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("gitlab");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLoginMethod", () => {
|
||||
it("returns null when no login method is set", () => {
|
||||
expect(getLoginMethod()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the stored login method", () => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, "github");
|
||||
expect(getLoginMethod()).toBe(LoginMethod.GITHUB);
|
||||
});
|
||||
|
||||
it("returns correct login method for all types", () => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, "gitlab");
|
||||
expect(getLoginMethod()).toBe(LoginMethod.GITLAB);
|
||||
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, "bitbucket");
|
||||
expect(getLoginMethod()).toBe(LoginMethod.BITBUCKET);
|
||||
|
||||
localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, "azure_devops");
|
||||
expect(getLoginMethod()).toBe(LoginMethod.AZURE_DEVOPS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearLoginData", () => {
|
||||
it("removes the login method from local storage", () => {
|
||||
setLoginMethod(LoginMethod.GITHUB);
|
||||
expect(getLoginMethod()).toBe(LoginMethod.GITHUB);
|
||||
|
||||
clearLoginData();
|
||||
expect(getLoginMethod()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not throw when no login method is set", () => {
|
||||
expect(() => clearLoginData()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CTA utilities", () => {
|
||||
describe("isCTADismissed", () => {
|
||||
it("returns false when CTA has not been dismissed", () => {
|
||||
expect(isCTADismissed("homepage")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when CTA has been dismissed", () => {
|
||||
localStorage.setItem("homepage-cta-dismissed", "true");
|
||||
expect(isCTADismissed("homepage")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when storage value is not 'true'", () => {
|
||||
localStorage.setItem("homepage-cta-dismissed", "false");
|
||||
expect(isCTADismissed("homepage")).toBe(false);
|
||||
|
||||
localStorage.setItem("homepage-cta-dismissed", "invalid");
|
||||
expect(isCTADismissed("homepage")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCTADismissed", () => {
|
||||
it("sets the CTA as dismissed in local storage", () => {
|
||||
setCTADismissed("homepage");
|
||||
expect(localStorage.getItem("homepage-cta-dismissed")).toBe("true");
|
||||
});
|
||||
|
||||
it("generates correct key for homepage location", () => {
|
||||
setCTADismissed("homepage");
|
||||
expect(localStorage.getItem("homepage-cta-dismissed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("storage key format", () => {
|
||||
it("uses the correct key format: {location}-cta-dismissed", () => {
|
||||
setCTADismissed("homepage");
|
||||
|
||||
// Verify key exists with correct format
|
||||
expect(localStorage.getItem("homepage-cta-dismissed")).toBe("true");
|
||||
|
||||
// Verify other keys don't exist
|
||||
expect(localStorage.getItem("cta-dismissed")).toBeNull();
|
||||
expect(localStorage.getItem("homepage")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistence", () => {
|
||||
it("dismissed state persists across multiple reads", () => {
|
||||
setCTADismissed("homepage");
|
||||
|
||||
expect(isCTADismissed("homepage")).toBe(true);
|
||||
expect(isCTADismissed("homepage")).toBe(true);
|
||||
expect(isCTADismissed("homepage")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,10 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { PermissionKey } from "#/utils/org/permissions";
|
||||
import { OrganizationMember, OrganizationsQueryData } from "#/types/org";
|
||||
import {
|
||||
getAvailableRolesAUserCanAssign,
|
||||
getActiveOrganizationUser,
|
||||
} from "#/utils/org/permission-checks";
|
||||
import { getSelectedOrganizationIdFromStore } from "#/stores/selected-organization-store";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
|
||||
// Mock dependencies
|
||||
// Mock dependencies for getActiveOrganizationUser tests
|
||||
vi.mock("#/api/organization-service/organization-service.api", () => ({
|
||||
organizationService: {
|
||||
getMe: vi.fn(),
|
||||
getOrganizations: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -20,60 +12,49 @@ vi.mock("#/stores/selected-organization-store", () => ({
|
||||
getSelectedOrganizationIdFromStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/query-client-getters", () => ({
|
||||
getMeFromQueryClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/query-client-config", () => ({
|
||||
queryClient: {
|
||||
getQueryData: vi.fn(),
|
||||
fetchQuery: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Test fixtures
|
||||
const mockUser: OrganizationMember = {
|
||||
org_id: "org-1",
|
||||
user_id: "user-1",
|
||||
email: "test@example.com",
|
||||
role: "admin",
|
||||
llm_api_key: "",
|
||||
max_iterations: 100,
|
||||
llm_model: "gpt-4",
|
||||
llm_api_key_for_byor: null,
|
||||
llm_base_url: "",
|
||||
status: "active",
|
||||
};
|
||||
|
||||
const mockOrganizationsData: OrganizationsQueryData = {
|
||||
items: [
|
||||
{ id: "org-1", name: "Org 1" },
|
||||
{ id: "org-2", name: "Org 2" },
|
||||
] as OrganizationsQueryData["items"],
|
||||
currentOrgId: "org-1",
|
||||
};
|
||||
// Import after mocks are set up
|
||||
import {
|
||||
getAvailableRolesAUserCanAssign,
|
||||
getActiveOrganizationUser,
|
||||
} from "#/utils/org/permission-checks";
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { getSelectedOrganizationIdFromStore } from "#/stores/selected-organization-store";
|
||||
import { getMeFromQueryClient } from "#/utils/query-client-getters";
|
||||
|
||||
describe("getAvailableRolesAUserCanAssign", () => {
|
||||
it("returns empty array if user has no permissions", () => {
|
||||
const result = getAvailableRolesAUserCanAssign([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
it("returns empty array if user has no permissions", () => {
|
||||
const result = getAvailableRolesAUserCanAssign([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns only roles the user has permission for", () => {
|
||||
const userPermissions: PermissionKey[] = [
|
||||
"change_user_role:member",
|
||||
"change_user_role:admin",
|
||||
];
|
||||
const result = getAvailableRolesAUserCanAssign(userPermissions);
|
||||
expect(result.sort()).toEqual(["admin", "member"].sort());
|
||||
});
|
||||
it("returns only roles the user has permission for", () => {
|
||||
const userPermissions: PermissionKey[] = [
|
||||
"change_user_role:member",
|
||||
"change_user_role:admin",
|
||||
];
|
||||
const result = getAvailableRolesAUserCanAssign(userPermissions);
|
||||
expect(result.sort()).toEqual(["admin", "member"].sort());
|
||||
});
|
||||
|
||||
it("returns all roles if user has all permissions", () => {
|
||||
const allPermissions: PermissionKey[] = [
|
||||
"change_user_role:member",
|
||||
"change_user_role:admin",
|
||||
"change_user_role:owner",
|
||||
];
|
||||
const result = getAvailableRolesAUserCanAssign(allPermissions);
|
||||
expect(result.sort()).toEqual(["member", "admin", "owner"].sort());
|
||||
});
|
||||
it("returns all roles if user has all permissions", () => {
|
||||
const allPermissions: PermissionKey[] = [
|
||||
"change_user_role:member",
|
||||
"change_user_role:admin",
|
||||
"change_user_role:owner",
|
||||
];
|
||||
const result = getAvailableRolesAUserCanAssign(allPermissions);
|
||||
expect(result.sort()).toEqual(["member", "admin", "owner"].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActiveOrganizationUser", () => {
|
||||
@@ -81,147 +62,18 @@ describe("getActiveOrganizationUser", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("when orgId exists in store", () => {
|
||||
it("should fetch user directly using stored orgId", async () => {
|
||||
// Arrange
|
||||
vi.mocked(getSelectedOrganizationIdFromStore).mockReturnValue("org-1");
|
||||
vi.mocked(queryClient.fetchQuery).mockResolvedValue(mockUser);
|
||||
it("should return undefined when API call throws an error", async () => {
|
||||
// Arrange: orgId exists, cache is empty, API call fails
|
||||
vi.mocked(getSelectedOrganizationIdFromStore).mockReturnValue("org-1");
|
||||
vi.mocked(getMeFromQueryClient).mockReturnValue(undefined);
|
||||
vi.mocked(organizationService.getMe).mockRejectedValue(
|
||||
new Error("Network error"),
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await getActiveOrganizationUser();
|
||||
// Act
|
||||
const result = await getActiveOrganizationUser();
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(queryClient.getQueryData).not.toHaveBeenCalled();
|
||||
expect(queryClient.fetchQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ["organizations", "org-1", "me"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should return undefined when user fetch fails", async () => {
|
||||
// Arrange
|
||||
vi.mocked(getSelectedOrganizationIdFromStore).mockReturnValue("org-1");
|
||||
vi.mocked(queryClient.fetchQuery).mockRejectedValue(
|
||||
new Error("Network error"),
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await getActiveOrganizationUser();
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when orgId is null in store (page refresh scenario)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getSelectedOrganizationIdFromStore).mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("should use currentOrgId from cached organizations data", async () => {
|
||||
// Arrange
|
||||
vi.mocked(queryClient.getQueryData).mockReturnValue(
|
||||
mockOrganizationsData,
|
||||
);
|
||||
vi.mocked(queryClient.fetchQuery).mockResolvedValue(mockUser);
|
||||
|
||||
// Act
|
||||
const result = await getActiveOrganizationUser();
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(queryClient.getQueryData).toHaveBeenCalledWith(["organizations"]);
|
||||
expect(queryClient.fetchQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ["organizations", "org-1", "me"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should fallback to first org when currentOrgId is null", async () => {
|
||||
// Arrange
|
||||
const dataWithoutCurrentOrg: OrganizationsQueryData = {
|
||||
items: [
|
||||
{ id: "first-org" },
|
||||
{ id: "second-org" },
|
||||
] as OrganizationsQueryData["items"],
|
||||
currentOrgId: null,
|
||||
};
|
||||
vi.mocked(queryClient.getQueryData).mockReturnValue(
|
||||
dataWithoutCurrentOrg,
|
||||
);
|
||||
vi.mocked(queryClient.fetchQuery).mockResolvedValue(mockUser);
|
||||
|
||||
// Act
|
||||
const result = await getActiveOrganizationUser();
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(queryClient.fetchQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ["organizations", "first-org", "me"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should fetch organizations when not in cache", async () => {
|
||||
// Arrange
|
||||
vi.mocked(queryClient.getQueryData).mockReturnValue(undefined);
|
||||
vi.mocked(queryClient.fetchQuery)
|
||||
.mockResolvedValueOnce(mockOrganizationsData) // First call: fetch organizations
|
||||
.mockResolvedValueOnce(mockUser); // Second call: fetch user
|
||||
|
||||
// Act
|
||||
const result = await getActiveOrganizationUser();
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(queryClient.fetchQuery).toHaveBeenCalledTimes(2);
|
||||
expect(queryClient.fetchQuery).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
queryKey: ["organizations"],
|
||||
}),
|
||||
);
|
||||
expect(queryClient.fetchQuery).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
queryKey: ["organizations", "org-1", "me"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should return undefined when fetching organizations fails", async () => {
|
||||
// Arrange
|
||||
vi.mocked(queryClient.getQueryData).mockReturnValue(undefined);
|
||||
vi.mocked(queryClient.fetchQuery).mockRejectedValue(
|
||||
new Error("Failed to fetch organizations"),
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await getActiveOrganizationUser();
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined when no organizations exist", async () => {
|
||||
// Arrange
|
||||
const emptyData: OrganizationsQueryData = {
|
||||
items: [],
|
||||
currentOrgId: null,
|
||||
};
|
||||
vi.mocked(queryClient.getQueryData).mockReturnValue(emptyData);
|
||||
|
||||
// Act
|
||||
const result = await getActiveOrganizationUser();
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
// Should not attempt to fetch user since there's no orgId
|
||||
expect(queryClient.fetchQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
// Assert: should return undefined instead of propagating the error
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
// Mock translations
|
||||
const t = (key: string) => {
|
||||
const translations: { [key: string]: string } = {
|
||||
COMMON$WAITING_FOR_SANDBOX: "Waiting for sandbox",
|
||||
COMMON$WAITING_FOR_SANDBOX: "Waiting For Sandbox",
|
||||
COMMON$STOPPING: "Stopping",
|
||||
COMMON$STARTING: "Starting",
|
||||
COMMON$SERVER_STOPPED: "Server stopped",
|
||||
@@ -69,7 +69,7 @@ describe("getStatusText", () => {
|
||||
t,
|
||||
});
|
||||
|
||||
expect(result).toBe("Waiting for sandbox");
|
||||
expect(result).toBe(t(I18nKey.COMMON$WAITING_FOR_SANDBOX));
|
||||
});
|
||||
|
||||
it("returns task detail when task status is ERROR and detail exists", () => {
|
||||
|
||||
@@ -13,9 +13,6 @@ import { TermsAndPrivacyNotice } from "#/components/shared/terms-and-privacy-not
|
||||
import { useRecaptcha } from "#/hooks/use-recaptcha";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags";
|
||||
import { LoginCTA } from "./login-cta";
|
||||
|
||||
export interface LoginContentProps {
|
||||
githubAuthUrl: string | null;
|
||||
@@ -180,133 +177,125 @@ export function LoginContent({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col md:flex-row items-center md:items-stretch gap-6 h-full",
|
||||
)}
|
||||
className="flex flex-col items-center w-full gap-12.5"
|
||||
data-testid="login-content"
|
||||
>
|
||||
<div
|
||||
className={cn("flex flex-col items-center w-full gap-12.5")}
|
||||
data-testid="login-content"
|
||||
>
|
||||
<div>
|
||||
<OpenHandsLogoWhite width={106} height={72} />
|
||||
</div>
|
||||
|
||||
<h1 className="text-[39px] leading-5 font-medium text-white text-center">
|
||||
{t(I18nKey.AUTH$LETS_GET_STARTED)}
|
||||
</h1>
|
||||
|
||||
{shouldShownHelperText && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{emailVerified && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t(I18nKey.AUTH$EMAIL_VERIFIED_PLEASE_LOGIN)}
|
||||
</p>
|
||||
)}
|
||||
{hasDuplicatedEmail && (
|
||||
<p className="text-sm text-danger text-center">
|
||||
{t(I18nKey.AUTH$DUPLICATE_EMAIL_ERROR)}
|
||||
</p>
|
||||
)}
|
||||
{recaptchaBlocked && (
|
||||
<p className="text-sm text-danger text-center max-w-125">
|
||||
{t(I18nKey.AUTH$RECAPTCHA_BLOCKED)}
|
||||
</p>
|
||||
)}
|
||||
{hasInvitation && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t(I18nKey.AUTH$INVITATION_PENDING)}
|
||||
</p>
|
||||
)}
|
||||
{showBitbucket && (
|
||||
<p className="text-sm text-white text-center max-w-125">
|
||||
{t(I18nKey.AUTH$BITBUCKET_SIGNUP_DISABLED)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{noProvidersConfigured ? (
|
||||
<div className="text-center p-4 text-muted-foreground">
|
||||
{t(I18nKey.AUTH$NO_PROVIDERS_CONFIGURED)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{showGithub && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGitHubAuth}
|
||||
className={`${buttonBaseClasses} bg-[#9E28B0] text-white`}
|
||||
>
|
||||
<GitHubLogo width={14} height={14} className="shrink-0" />
|
||||
<span className={buttonLabelClasses}>
|
||||
{t(I18nKey.GITHUB$CONNECT_TO_GITHUB)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showGitlab && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGitLabAuth}
|
||||
className={`${buttonBaseClasses} bg-[#FC6B0E] text-white`}
|
||||
>
|
||||
<GitLabLogo width={14} height={14} className="shrink-0" />
|
||||
<span className={buttonLabelClasses}>
|
||||
{t(I18nKey.GITLAB$CONNECT_TO_GITLAB)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showBitbucket && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBitbucketAuth}
|
||||
className={`${buttonBaseClasses} bg-[#2684FF] text-white`}
|
||||
>
|
||||
<BitbucketLogo width={14} height={14} className="shrink-0" />
|
||||
<span className={buttonLabelClasses}>
|
||||
{t(I18nKey.BITBUCKET$CONNECT_TO_BITBUCKET)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showBitbucketDataCenter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBitbucketDataCenterAuth}
|
||||
className={`${buttonBaseClasses} bg-[#2684FF] text-white`}
|
||||
>
|
||||
<BitbucketLogo width={14} height={14} className="shrink-0" />
|
||||
<span className={buttonLabelClasses}>
|
||||
{t(
|
||||
I18nKey.BITBUCKET_DATA_CENTER$CONNECT_TO_BITBUCKET_DATA_CENTER,
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showEnterpriseSso && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEnterpriseSsoAuth}
|
||||
className={`${buttonBaseClasses} bg-[#374151] text-white`}
|
||||
>
|
||||
<FaUserShield size={14} className="shrink-0" />
|
||||
<span className={buttonLabelClasses}>
|
||||
{t(I18nKey.ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TermsAndPrivacyNotice className="max-w-[320px] text-[#A3A3A3]" />
|
||||
<div>
|
||||
<OpenHandsLogoWhite width={106} height={72} />
|
||||
</div>
|
||||
|
||||
{appMode === "saas" && ENABLE_PROJ_USER_JOURNEY() && <LoginCTA />}
|
||||
<h1 className="text-[39px] leading-5 font-medium text-white text-center">
|
||||
{t(I18nKey.AUTH$LETS_GET_STARTED)}
|
||||
</h1>
|
||||
|
||||
{shouldShownHelperText && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{emailVerified && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t(I18nKey.AUTH$EMAIL_VERIFIED_PLEASE_LOGIN)}
|
||||
</p>
|
||||
)}
|
||||
{hasDuplicatedEmail && (
|
||||
<p className="text-sm text-danger text-center">
|
||||
{t(I18nKey.AUTH$DUPLICATE_EMAIL_ERROR)}
|
||||
</p>
|
||||
)}
|
||||
{recaptchaBlocked && (
|
||||
<p className="text-sm text-danger text-center max-w-125">
|
||||
{t(I18nKey.AUTH$RECAPTCHA_BLOCKED)}
|
||||
</p>
|
||||
)}
|
||||
{hasInvitation && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t(I18nKey.AUTH$INVITATION_PENDING)}
|
||||
</p>
|
||||
)}
|
||||
{showBitbucket && (
|
||||
<p className="text-sm text-white text-center max-w-125">
|
||||
{t(I18nKey.AUTH$BITBUCKET_SIGNUP_DISABLED)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{noProvidersConfigured ? (
|
||||
<div className="text-center p-4 text-muted-foreground">
|
||||
{t(I18nKey.AUTH$NO_PROVIDERS_CONFIGURED)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{showGithub && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGitHubAuth}
|
||||
className={`${buttonBaseClasses} bg-[#9E28B0] text-white`}
|
||||
>
|
||||
<GitHubLogo width={14} height={14} className="shrink-0" />
|
||||
<span className={buttonLabelClasses}>
|
||||
{t(I18nKey.GITHUB$CONNECT_TO_GITHUB)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showGitlab && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGitLabAuth}
|
||||
className={`${buttonBaseClasses} bg-[#FC6B0E] text-white`}
|
||||
>
|
||||
<GitLabLogo width={14} height={14} className="shrink-0" />
|
||||
<span className={buttonLabelClasses}>
|
||||
{t(I18nKey.GITLAB$CONNECT_TO_GITLAB)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showBitbucket && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBitbucketAuth}
|
||||
className={`${buttonBaseClasses} bg-[#2684FF] text-white`}
|
||||
>
|
||||
<BitbucketLogo width={14} height={14} className="shrink-0" />
|
||||
<span className={buttonLabelClasses}>
|
||||
{t(I18nKey.BITBUCKET$CONNECT_TO_BITBUCKET)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showBitbucketDataCenter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBitbucketDataCenterAuth}
|
||||
className={`${buttonBaseClasses} bg-[#2684FF] text-white`}
|
||||
>
|
||||
<BitbucketLogo width={14} height={14} className="shrink-0" />
|
||||
<span className={buttonLabelClasses}>
|
||||
{t(
|
||||
I18nKey.BITBUCKET_DATA_CENTER$CONNECT_TO_BITBUCKET_DATA_CENTER,
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showEnterpriseSso && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEnterpriseSsoAuth}
|
||||
className={`${buttonBaseClasses} bg-[#374151] text-white`}
|
||||
>
|
||||
<FaUserShield size={14} className="shrink-0" />
|
||||
<span className={buttonLabelClasses}>
|
||||
{t(I18nKey.ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TermsAndPrivacyNotice className="max-w-[320px] text-[#A3A3A3]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card } from "#/ui/card";
|
||||
import { CardTitle } from "#/ui/card-title";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
import StackedIcon from "#/icons/stacked.svg?react";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
export function LoginCTA() {
|
||||
const { t } = useTranslation();
|
||||
const { trackSaasSelfhostedInquiry } = useTracking();
|
||||
|
||||
const handleLearnMoreClick = () => {
|
||||
trackSaasSelfhostedInquiry({ location: "login_page" });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
testId="login-cta"
|
||||
theme="dark"
|
||||
className={cn("w-full max-w-80 h-auto flex-col", "cta-card-gradient")}
|
||||
>
|
||||
<div className={cn("flex flex-col gap-[11px] p-6")}>
|
||||
<div className={cn("size-10")}>
|
||||
<StackedIcon width={40} height={40} />
|
||||
</div>
|
||||
|
||||
<CardTitle>{t(I18nKey.CTA$ENTERPRISE)}</CardTitle>
|
||||
|
||||
<Typography.Text className="text-[#8C8C8C] font-inter font-normal text-sm leading-5">
|
||||
{t(I18nKey.CTA$ENTERPRISE_DEPLOY)}
|
||||
</Typography.Text>
|
||||
|
||||
<ul
|
||||
className={cn(
|
||||
"text-[#8C8C8C] font-inter font-normal text-sm leading-5 list-disc list-inside flex flex-col gap-1",
|
||||
)}
|
||||
>
|
||||
<li>{t(I18nKey.CTA$FEATURE_ON_PREMISES)}</li>
|
||||
<li>{t(I18nKey.CTA$FEATURE_DATA_CONTROL)}</li>
|
||||
<li>{t(I18nKey.CTA$FEATURE_COMPLIANCE)}</li>
|
||||
<li>{t(I18nKey.CTA$FEATURE_SUPPORT)}</li>
|
||||
</ul>
|
||||
|
||||
<div className={cn("h-10 flex justify-start")}>
|
||||
<a
|
||||
href="https://openhands.dev/enterprise/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleLearnMoreClick}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
"h-10 px-4 rounded",
|
||||
"bg-[#050505] border border-[#242424]",
|
||||
"text-white hover:bg-[#0a0a0a]",
|
||||
"font-semibold text-sm",
|
||||
)}
|
||||
>
|
||||
{t(I18nKey.CTA$LEARN_MORE)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
|
||||
interface ContextMenuContainerProps {
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
testId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ContextMenuContainer({
|
||||
children,
|
||||
onClose,
|
||||
testId,
|
||||
className,
|
||||
}: ContextMenuContainerProps) {
|
||||
const ref = useClickOutsideElement<HTMLDivElement>(onClose);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
// Base styling - same for ALL modes (SaaS, OSS, mobile, desktop)
|
||||
"absolute rounded-[12px] p-[25px]",
|
||||
"bg-[#050505] border border-[#242424]",
|
||||
"text-white overflow-hidden z-[9999]",
|
||||
"context-menu-box-shadow",
|
||||
// Positioning
|
||||
"right-0 md:right-auto md:left-full md:bottom-0",
|
||||
"w-fit",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row gap-4 items-stretch">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { Card } from "#/ui/card";
|
||||
import { CardTitle } from "#/ui/card-title";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import StackedIcon from "#/icons/stacked.svg?react";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
export function ContextMenuCTA() {
|
||||
const { t } = useTranslation();
|
||||
const { trackSaasSelfhostedInquiry } = useTracking();
|
||||
|
||||
const handleLearnMoreClick = () => {
|
||||
trackSaasSelfhostedInquiry({ location: "context_menu" });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
testId="context-menu-cta"
|
||||
theme="dark"
|
||||
className={cn(
|
||||
"w-[286px] min-h-[200px] rounded-[6px]",
|
||||
"flex-col justify-end",
|
||||
"cta-card-gradient",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-testid="context-menu-cta-content"
|
||||
className={cn("flex flex-col gap-[11px] p-[25px]")}
|
||||
>
|
||||
<StackedIcon width={40} height={40} />
|
||||
|
||||
<CardTitle>{t(I18nKey.CTA$ENTERPRISE_TITLE)}</CardTitle>
|
||||
|
||||
<Typography.Text
|
||||
className={cn(
|
||||
"text-[#8C8C8C] font-inter font-normal",
|
||||
"text-[14px] leading-[20px]",
|
||||
)}
|
||||
>
|
||||
{t(I18nKey.CTA$ENTERPRISE_DESCRIPTION)}
|
||||
</Typography.Text>
|
||||
|
||||
<div className="flex mt-auto">
|
||||
<a
|
||||
href="https://openhands.dev/enterprise/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleLearnMoreClick}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
"h-[40px] px-4 rounded-[4px]",
|
||||
"bg-[#050505] border border-[#242424]",
|
||||
"text-white hover:bg-[#0a0a0a]",
|
||||
"font-semibold text-sm",
|
||||
)}
|
||||
>
|
||||
{t(I18nKey.CTA$LEARN_MORE)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+3
-9
@@ -20,7 +20,7 @@ export function ConversationTabTitle({
|
||||
conversationKey,
|
||||
}: ConversationTabTitleProps) {
|
||||
const { t } = useTranslation();
|
||||
const { refetch, isFetching } = useUnifiedGetGitChanges();
|
||||
const { refetch } = useUnifiedGetGitChanges();
|
||||
const { handleBuildPlanClick } = useHandleBuildPlanClick();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { planContent } = useConversationStore();
|
||||
@@ -41,16 +41,10 @@ export function ConversationTabTitle({
|
||||
{conversationKey === "editor" && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-[26px] py-1 justify-center items-center gap-[10px] rounded-[7px] hover:enabled:bg-[#474A54] cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex w-[26px] py-1 justify-center items-center gap-[10px] rounded-[7px] hover:bg-[#474A54] cursor-pointer"
|
||||
onClick={handleRefresh}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshIcon
|
||||
width={12.75}
|
||||
height={15}
|
||||
color="#ffffff"
|
||||
className={isFetching ? "animate-spin" : ""}
|
||||
/>
|
||||
<RefreshIcon width={12.75} height={15} color="#ffffff" />
|
||||
</button>
|
||||
)}
|
||||
{conversationKey === "planner" && (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { usePostHog } from "posthog-js/react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { H2, Text } from "#/ui/typography";
|
||||
import CheckCircleFillIcon from "#/icons/check-circle-fill.svg?react";
|
||||
import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags";
|
||||
import { PROJ_USER_JOURNEY } from "#/utils/feature-flags";
|
||||
|
||||
const ENTERPRISE_FEATURE_KEYS: I18nKey[] = [
|
||||
I18nKey.ENTERPRISE$FEATURE_DATA_PRIVACY,
|
||||
@@ -16,7 +16,7 @@ export function EnterpriseBanner() {
|
||||
const { t } = useTranslation();
|
||||
const posthog = usePostHog();
|
||||
|
||||
if (!ENABLE_PROJ_USER_JOURNEY()) {
|
||||
if (!PROJ_USER_JOURNEY()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { Card } from "#/ui/card";
|
||||
import { CardTitle } from "#/ui/card-title";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { setCTADismissed } from "#/utils/local-storage";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
import CloseIcon from "#/icons/close.svg?react";
|
||||
|
||||
interface HomepageCTAProps {
|
||||
setShouldShowCTA: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export function HomepageCTA({ setShouldShowCTA }: HomepageCTAProps) {
|
||||
const { t } = useTranslation();
|
||||
const { trackSaasSelfhostedInquiry } = useTracking();
|
||||
|
||||
const handleClose = () => {
|
||||
setCTADismissed("homepage");
|
||||
setShouldShowCTA(false);
|
||||
};
|
||||
|
||||
const handleLearnMoreClick = () => {
|
||||
trackSaasSelfhostedInquiry({ location: "home_page" });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card theme="dark" className={cn("w-[320px] cta-card-gradient")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className={cn(
|
||||
"absolute top-3 right-3 size-7 rounded-full",
|
||||
"border border-[#242424] bg-[#0A0A0A]",
|
||||
"flex items-center justify-center",
|
||||
"text-white/60 hover:text-white cursor-pointer",
|
||||
"shadow-[0px_1px_2px_-1px_#0000001A,0px_1px_3px_0px_#0000001A]",
|
||||
)}
|
||||
aria-label="Close"
|
||||
>
|
||||
<CloseIcon width={16} height={16} />
|
||||
</button>
|
||||
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardTitle className="font-inter font-semibold text-xl leading-7 tracking-normal text-[#FAFAFA]">
|
||||
{t(I18nKey.CTA$ENTERPRISE_TITLE)}
|
||||
</CardTitle>
|
||||
|
||||
<Typography.Text className="font-inter font-normal text-sm leading-5 tracking-normal text-[#8C8C8C]">
|
||||
{t(I18nKey.CTA$ENTERPRISE_DESCRIPTION)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://openhands.dev/enterprise/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleLearnMoreClick}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
"w-fit h-10 px-4 rounded",
|
||||
"bg-[#050505] border border-[#242424]",
|
||||
"text-white hover:bg-[#1a1a1a]",
|
||||
"font-semibold text-sm",
|
||||
)}
|
||||
>
|
||||
{t(I18nKey.CTA$LEARN_MORE)}
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export function NewConversation() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card className="flex-col p-5 gap-2.5 min-h-[286px] md:min-h-auto w-full">
|
||||
<Card>
|
||||
<CardTitle icon={<PlusIcon width={17} height={14} />}>
|
||||
{t(I18nKey.COMMON$START_FROM_SCRATCH)}
|
||||
</CardTitle>
|
||||
|
||||
@@ -1,56 +1,35 @@
|
||||
import { StepOption } from "./step-option";
|
||||
import { StepInput } from "./step-input";
|
||||
|
||||
export interface Option {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface InputField {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface StepContentProps {
|
||||
options?: Option[];
|
||||
inputFields?: InputField[];
|
||||
selectedOptionIds: string[];
|
||||
inputValues?: Record<string, string>;
|
||||
options: Option[];
|
||||
selectedOptionId: string | null;
|
||||
onSelectOption: (optionId: string) => void;
|
||||
onInputChange?: (fieldId: string, value: string) => void;
|
||||
}
|
||||
|
||||
export function StepContent({
|
||||
options,
|
||||
inputFields,
|
||||
selectedOptionIds,
|
||||
inputValues = {},
|
||||
selectedOptionId,
|
||||
onSelectOption,
|
||||
onInputChange,
|
||||
}: StepContentProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="step-content"
|
||||
className="flex flex-col mt-8 mb-8 gap-[12px] w-full"
|
||||
>
|
||||
{options?.map((option) => (
|
||||
{options.map((option) => (
|
||||
<StepOption
|
||||
key={option.id}
|
||||
id={option.id}
|
||||
label={option.label}
|
||||
selected={selectedOptionIds.includes(option.id)}
|
||||
selected={selectedOptionId === option.id}
|
||||
onClick={() => onSelectOption(option.id)}
|
||||
/>
|
||||
))}
|
||||
{inputFields?.map((field) => (
|
||||
<StepInput
|
||||
key={field.id}
|
||||
id={field.id}
|
||||
label={field.label}
|
||||
value={inputValues[field.id] || ""}
|
||||
onChange={(value) => onInputChange?.(field.id, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,17 +3,11 @@ import { cn } from "#/utils/utils";
|
||||
|
||||
interface StepHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
}
|
||||
|
||||
function StepHeader({
|
||||
title,
|
||||
subtitle,
|
||||
currentStep,
|
||||
totalSteps,
|
||||
}: StepHeaderProps) {
|
||||
function StepHeader({ title, currentStep, totalSteps }: StepHeaderProps) {
|
||||
return (
|
||||
<div data-testid="step-header" className="flex flex-col items-center gap-2">
|
||||
<div className="flex justify-center gap-2 mb-2">
|
||||
@@ -30,11 +24,6 @@ function StepHeader({
|
||||
<Typography.Text className="text-2xl font-semibold text-content text-center">
|
||||
{title}
|
||||
</Typography.Text>
|
||||
{subtitle && (
|
||||
<Typography.Text className="text-sm text-neutral-400 text-center">
|
||||
{subtitle}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
interface StepInputProps {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function StepInput({ id, label, value, onChange }: StepInputProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 w-full">
|
||||
<label
|
||||
htmlFor={`step-input-${id}`}
|
||||
className="text-sm font-medium text-neutral-400 cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={`step-input-${id}`}
|
||||
data-testid={`step-input-${id}`}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded-md border border-[#3a3a3a] bg-transparent px-4 py-2.5 text-sm text-white placeholder:text-neutral-500 focus:border-white focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,7 +56,6 @@ export function OrgSelector() {
|
||||
label: getOrgDisplayName(org),
|
||||
})) || []
|
||||
}
|
||||
className="bg-[#1F1F1F66] border-[#242424]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { FiUsers } from "react-icons/fi";
|
||||
import { useLogout } from "#/hooks/mutation/use-logout";
|
||||
import { OrganizationUserRole } from "#/types/org";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { OrgSelector } from "../org/org-selector";
|
||||
@@ -17,16 +18,11 @@ import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
|
||||
import DocumentIcon from "#/icons/document.svg?react";
|
||||
import { Divider } from "#/ui/divider";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
import { ContextMenuContainer } from "../context-menu/context-menu-container";
|
||||
import { ContextMenuCTA } from "../context-menu/context-menu-cta";
|
||||
import { useShouldHideOrgSelector } from "#/hooks/use-should-hide-org-selector";
|
||||
import { useBreakpoint } from "#/hooks/use-breakpoint";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags";
|
||||
|
||||
// Shared className for context menu list items in the user context menu
|
||||
const contextMenuListItemClassName = cn(
|
||||
"flex items-center gap-2 p-2 h-auto hover:bg-white/10 hover:text-white rounded text-xs",
|
||||
"flex items-center gap-2 p-2 h-auto hover:bg-white/10 hover:text-white rounded",
|
||||
);
|
||||
|
||||
interface UserContextMenuProps {
|
||||
@@ -44,10 +40,9 @@ export function UserContextMenu({
|
||||
const navigate = useNavigate();
|
||||
const { mutate: logout } = useLogout();
|
||||
const { isPersonalOrg } = useOrgTypeAndAccess();
|
||||
const ref = useClickOutsideElement<HTMLDivElement>(onClose);
|
||||
const settingsNavItems = useSettingsNavItems();
|
||||
const shouldHideSelector = useShouldHideOrgSelector();
|
||||
const isMobile = useBreakpoint(768);
|
||||
const { data: config } = useConfig();
|
||||
|
||||
// Filter out org routes since they're handled separately via buttons in this menu
|
||||
const navItems = settingsNavItems.filter(
|
||||
@@ -56,10 +51,7 @@ export function UserContextMenu({
|
||||
);
|
||||
|
||||
const isMember = type === "member";
|
||||
const isSaasMode = config?.app_mode === "saas";
|
||||
|
||||
// CTA only renders in SaaS desktop with feature flag enabled
|
||||
const showCta = isSaasMode && !isMobile && ENABLE_PROJ_USER_JOURNEY();
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
onClose();
|
||||
@@ -81,93 +73,96 @@ export function UserContextMenu({
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenuContainer testId="user-context-menu" onClose={onClose}>
|
||||
<div className="flex flex-col gap-3 w-[248px]">
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{t(I18nKey.ORG$ACCOUNT)}
|
||||
</h3>
|
||||
<div
|
||||
data-testid="user-context-menu"
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-72 flex flex-col gap-3 bg-tertiary border border-tertiary rounded-xl p-4 context-menu-box-shadow",
|
||||
"text-sm absolute left-full bottom-0 z-101",
|
||||
)}
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{t(I18nKey.ORG$ACCOUNT)}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
{!shouldHideSelector && (
|
||||
<div className="w-full relative">
|
||||
<OrgSelector />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMember && !isPersonalOrg && (
|
||||
<div className="flex flex-col items-start gap-0 w-full">
|
||||
<ContextMenuListItem
|
||||
onClick={handleInviteMemberClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<IoPersonAddOutline className="text-white" size={16} />
|
||||
{t(I18nKey.ORG$INVITE_ORG_MEMBERS)}
|
||||
</ContextMenuListItem>
|
||||
|
||||
<Divider className="my-1.5" />
|
||||
|
||||
<ContextMenuListItem
|
||||
onClick={handleManageAccountClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<IoCardOutline className="text-white" size={16} />
|
||||
{t(I18nKey.COMMON$ORGANIZATION)}
|
||||
</ContextMenuListItem>
|
||||
<ContextMenuListItem
|
||||
onClick={handleManageOrganizationMembersClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<FiUsers className="text-white shrink-0" size={16} />
|
||||
{t(I18nKey.ORG$ORGANIZATION_MEMBERS)}
|
||||
</ContextMenuListItem>
|
||||
<Divider className="my-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-start gap-0 w-full">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full text-xs"
|
||||
>
|
||||
{React.cloneElement(item.icon, {
|
||||
className: "text-white",
|
||||
width: 16,
|
||||
height: 16,
|
||||
} as React.SVGProps<SVGSVGElement>)}
|
||||
{t(item.text)}
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
{!shouldHideSelector && (
|
||||
<div className="w-full relative">
|
||||
<OrgSelector />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider className="my-1.5" />
|
||||
|
||||
{!isMember && !isPersonalOrg && (
|
||||
<div className="flex flex-col items-start gap-0 w-full">
|
||||
<a
|
||||
href="https://docs.openhands.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full text-xs"
|
||||
>
|
||||
<DocumentIcon className="text-white" width={16} height={16} />
|
||||
{t(I18nKey.SIDEBAR$DOCS)}
|
||||
</a>
|
||||
|
||||
<ContextMenuListItem
|
||||
onClick={handleLogout}
|
||||
onClick={handleInviteMemberClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<IoLogOutOutline className="text-white" size={16} />
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
|
||||
<IoPersonAddOutline className="text-white" size={14} />
|
||||
{t(I18nKey.ORG$INVITE_ORG_MEMBERS)}
|
||||
</ContextMenuListItem>
|
||||
|
||||
<Divider className="my-1.5" />
|
||||
|
||||
<ContextMenuListItem
|
||||
onClick={handleManageAccountClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<IoCardOutline className="text-white" size={14} />
|
||||
{t(I18nKey.COMMON$ORGANIZATION)}
|
||||
</ContextMenuListItem>
|
||||
<ContextMenuListItem
|
||||
onClick={handleManageOrganizationMembersClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<FiUsers className="text-white shrink-0" size={14} />
|
||||
{t(I18nKey.ORG$ORGANIZATION_MEMBERS)}
|
||||
</ContextMenuListItem>
|
||||
<Divider className="my-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-start gap-0 w-full">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full"
|
||||
>
|
||||
{React.cloneElement(item.icon, {
|
||||
className: "text-white",
|
||||
width: 14,
|
||||
height: 14,
|
||||
} as React.SVGProps<SVGSVGElement>)}
|
||||
{t(item.text)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Divider className="my-1.5" />
|
||||
|
||||
<div className="flex flex-col items-start gap-0 w-full">
|
||||
<a
|
||||
href="https://docs.openhands.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full"
|
||||
>
|
||||
<DocumentIcon className="text-white" width={14} height={14} />
|
||||
{t(I18nKey.SIDEBAR$DOCS)}
|
||||
</a>
|
||||
|
||||
<ContextMenuListItem
|
||||
onClick={handleLogout}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<IoLogOutOutline className="text-white" size={14} />
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
|
||||
</ContextMenuListItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCta && <ContextMenuCTA />}
|
||||
</ContextMenuContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ const getSearchActionContent = (
|
||||
if ("include" in action && action.include) {
|
||||
parts.push(`**Include:** \`${action.include}\``);
|
||||
}
|
||||
const { summary } = event as { summary?: string };
|
||||
if (summary) {
|
||||
parts.push(`**Summary:** ${summary}`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join("\n") : getNoContentActionContent();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Trans } from "react-i18next";
|
||||
import React from "react";
|
||||
import { OpenHandsEvent, ObservationEvent, ActionEvent } from "#/types/v1/core";
|
||||
import { OpenHandsEvent, ObservationEvent } from "#/types/v1/core";
|
||||
import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
|
||||
import { MonoComponent } from "../../../features/chat/mono-component";
|
||||
import { PathComponent } from "../../../features/chat/path-component";
|
||||
@@ -37,13 +37,6 @@ const createTitleFromKey = (
|
||||
);
|
||||
};
|
||||
|
||||
const getSummaryTitleForActionEvent = (
|
||||
event: ActionEvent,
|
||||
): React.ReactNode | null => {
|
||||
const summary = event.summary?.trim().replace(/\s+/g, " ") || "";
|
||||
return summary || null;
|
||||
};
|
||||
|
||||
// Action Event Processing
|
||||
const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => {
|
||||
// Early return if not an action event
|
||||
@@ -51,11 +44,6 @@ const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => {
|
||||
return "";
|
||||
}
|
||||
|
||||
const summaryTitle = getSummaryTitleForActionEvent(event);
|
||||
if (summaryTitle) {
|
||||
return summaryTitle;
|
||||
}
|
||||
|
||||
const actionType = event.action.kind;
|
||||
let actionKey = "";
|
||||
let actionValues: Record<string, unknown> = {};
|
||||
@@ -139,22 +127,12 @@ const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => {
|
||||
};
|
||||
|
||||
// Observation Event Processing
|
||||
const getObservationEventTitle = (
|
||||
event: OpenHandsEvent,
|
||||
correspondingAction?: ActionEvent,
|
||||
): React.ReactNode => {
|
||||
const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
|
||||
// Early return if not an observation event
|
||||
if (!isObservationEvent(event)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (correspondingAction) {
|
||||
const summaryTitle = getSummaryTitleForActionEvent(correspondingAction);
|
||||
if (summaryTitle) {
|
||||
return summaryTitle;
|
||||
}
|
||||
}
|
||||
|
||||
const observationType = event.observation.kind;
|
||||
let observationKey = "";
|
||||
let observationValues: Record<string, unknown> = {};
|
||||
@@ -230,10 +208,7 @@ const getObservationEventTitle = (
|
||||
return observationType;
|
||||
};
|
||||
|
||||
export const getEventContent = (
|
||||
event: OpenHandsEvent | SkillReadyEvent,
|
||||
correspondingAction?: ActionEvent,
|
||||
) => {
|
||||
export const getEventContent = (event: OpenHandsEvent | SkillReadyEvent) => {
|
||||
let title: React.ReactNode = "";
|
||||
let details: string | React.ReactNode = "";
|
||||
|
||||
@@ -251,7 +226,7 @@ export const getEventContent = (
|
||||
title = getActionEventTitle(event);
|
||||
details = getActionContent(event);
|
||||
} else if (isObservationEvent(event)) {
|
||||
title = getObservationEventTitle(event, correspondingAction);
|
||||
title = getObservationEventTitle(event);
|
||||
|
||||
// For TaskTrackerObservation, use React component instead of markdown
|
||||
if (event.observation.kind === "TaskTrackerObservation") {
|
||||
|
||||
+2
-4
@@ -1,4 +1,4 @@
|
||||
import { OpenHandsEvent, ActionEvent } from "#/types/v1/core";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { GenericEventMessage } from "../../../features/chat/generic-event-message";
|
||||
import { getEventContent } from "../event-content-helpers/get-event-content";
|
||||
import { getObservationResult } from "../event-content-helpers/get-observation-result";
|
||||
@@ -13,15 +13,13 @@ import { ObservationResultStatus } from "../../../features/chat/event-content-he
|
||||
interface GenericEventMessageWrapperProps {
|
||||
event: OpenHandsEvent | SkillReadyEvent;
|
||||
isLastMessage: boolean;
|
||||
correspondingAction?: ActionEvent;
|
||||
}
|
||||
|
||||
export function GenericEventMessageWrapper({
|
||||
event,
|
||||
isLastMessage,
|
||||
correspondingAction,
|
||||
}: GenericEventMessageWrapperProps) {
|
||||
const { title, details } = getEventContent(event, correspondingAction);
|
||||
const { title, details } = getEventContent(event);
|
||||
|
||||
// SkillReadyEvent is not an observation event, so skip the observation checks
|
||||
if (!isSkillReadyEvent(event)) {
|
||||
|
||||
@@ -265,11 +265,6 @@ export function EventMessage({
|
||||
<GenericEventMessageWrapper
|
||||
event={event}
|
||||
isLastMessage={isLastMessage}
|
||||
correspondingAction={
|
||||
correspondingAction && isActionEvent(correspondingAction)
|
||||
? correspondingAction
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export type OnboardingAppMode = "saas" | "self-hosted";
|
||||
|
||||
interface BaseOnboardingQuestion {
|
||||
id: string;
|
||||
app_mode: OnboardingAppMode[];
|
||||
questionKey: I18nKey;
|
||||
subtitleKey?: I18nKey;
|
||||
}
|
||||
|
||||
interface InputQuestion extends BaseOnboardingQuestion {
|
||||
type: "input";
|
||||
inputOptions: { key: I18nKey; id: string }[];
|
||||
}
|
||||
|
||||
interface SingleSelectQuestion extends BaseOnboardingQuestion {
|
||||
type: "single";
|
||||
answerOptions: { key: I18nKey; id: string }[];
|
||||
}
|
||||
|
||||
interface MultiSelectQuestion extends BaseOnboardingQuestion {
|
||||
type: "multi";
|
||||
answerOptions: { key: I18nKey; id: string }[];
|
||||
}
|
||||
|
||||
export type OnboardingQuestion =
|
||||
| InputQuestion
|
||||
| SingleSelectQuestion
|
||||
| MultiSelectQuestion;
|
||||
|
||||
export const ONBOARDING_FORM: OnboardingQuestion[] = [
|
||||
{
|
||||
id: "org_name",
|
||||
type: "input",
|
||||
app_mode: ["self-hosted"],
|
||||
questionKey: I18nKey.ONBOARDING$ORG_NAME_QUESTION,
|
||||
inputOptions: [
|
||||
{ key: I18nKey.ONBOARDING$ORG_NAME_INPUT_NAME, id: "org_name" },
|
||||
{ key: I18nKey.ONBOARDING$ORG_NAME_INPUT_DOMAIN, id: "org_domain" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "org_size",
|
||||
type: "single",
|
||||
app_mode: ["saas", "self-hosted"],
|
||||
questionKey: I18nKey.ONBOARDING$ORG_SIZE_QUESTION,
|
||||
subtitleKey: I18nKey.ONBOARDING$ORG_SIZE_SUBTITLE,
|
||||
answerOptions: [
|
||||
{ key: I18nKey.ONBOARDING$ORG_SIZE_SOLO, id: "solo" },
|
||||
{ key: I18nKey.ONBOARDING$ORG_SIZE_2_10, id: "org_2_10" },
|
||||
{ key: I18nKey.ONBOARDING$ORG_SIZE_11_50, id: "org_11_50" },
|
||||
{ key: I18nKey.ONBOARDING$ORG_SIZE_51_200, id: "org_51_200" },
|
||||
{ key: I18nKey.ONBOARDING$ORG_SIZE_200_PLUS, id: "org_200_plus" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "use_case",
|
||||
type: "multi",
|
||||
app_mode: ["saas", "self-hosted"],
|
||||
questionKey: I18nKey.ONBOARDING$USE_CASE_QUESTION,
|
||||
subtitleKey: I18nKey.ONBOARDING$USE_CASE_SUBTITLE,
|
||||
answerOptions: [
|
||||
{ key: I18nKey.ONBOARDING$USE_CASE_NEW_FEATURES, id: "new_features" },
|
||||
{
|
||||
key: I18nKey.ONBOARDING$USE_CASE_APP_FROM_SCRATCH,
|
||||
id: "app_from_scratch",
|
||||
},
|
||||
{ key: I18nKey.ONBOARDING$USE_CASE_FIXING_BUGS, id: "fixing_bugs" },
|
||||
{ key: I18nKey.ONBOARDING$USE_CASE_REFACTORING, id: "refactoring" },
|
||||
{
|
||||
key: I18nKey.ONBOARDING$USE_CASE_AUTOMATING_TASKS,
|
||||
id: "automating_tasks",
|
||||
},
|
||||
{ key: I18nKey.ONBOARDING$USE_CASE_NOT_SURE, id: "not_sure" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
type: "single",
|
||||
app_mode: ["saas"],
|
||||
questionKey: I18nKey.ONBOARDING$ROLE_QUESTION,
|
||||
answerOptions: [
|
||||
{
|
||||
key: I18nKey.ONBOARDING$ROLE_SOFTWARE_ENGINEER,
|
||||
id: "software_engineer",
|
||||
},
|
||||
{
|
||||
key: I18nKey.ONBOARDING$ROLE_ENGINEERING_MANAGER,
|
||||
id: "engineering_manager",
|
||||
},
|
||||
{ key: I18nKey.ONBOARDING$ROLE_CTO_FOUNDER, id: "cto_founder" },
|
||||
{
|
||||
key: I18nKey.ONBOARDING$ROLE_PRODUCT_OPERATIONS,
|
||||
id: "product_operations",
|
||||
},
|
||||
{ key: I18nKey.ONBOARDING$ROLE_STUDENT_HOBBYIST, id: "student_hobbyist" },
|
||||
{ key: I18nKey.ONBOARDING$ROLE_OTHER, id: "other" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
|
||||
type SubmitOnboardingArgs = {
|
||||
selections: Record<string, string | string[]>;
|
||||
selections: Record<string, string>;
|
||||
};
|
||||
|
||||
export const useSubmitOnboarding = () => {
|
||||
|
||||
@@ -2,11 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { useIsOnIntermediatePage } from "#/hooks/use-is-on-intermediate-page";
|
||||
|
||||
interface UseConfigOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export const useConfig = (options?: UseConfigOptions) => {
|
||||
export const useConfig = () => {
|
||||
const isOnIntermediatePage = useIsOnIntermediatePage();
|
||||
|
||||
return useQuery({
|
||||
@@ -14,6 +10,6 @@ export const useConfig = (options?: UseConfigOptions) => {
|
||||
queryFn: OptionService.getConfig,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes,
|
||||
enabled: options?.enabled ?? !isOnIntermediatePage,
|
||||
enabled: !isOnIntermediatePage,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -100,7 +100,6 @@ export const useUnifiedGetGitChanges = () => {
|
||||
return {
|
||||
data: orderedChanges,
|
||||
isLoading: result.isLoading,
|
||||
isFetching: result.isFetching,
|
||||
isSuccess: result.isSuccess,
|
||||
isError: result.isError,
|
||||
error: result.error,
|
||||
|
||||
@@ -110,9 +110,9 @@ export const useTracking = () => {
|
||||
orgSize,
|
||||
useCase,
|
||||
}: {
|
||||
role?: string;
|
||||
orgSize?: string;
|
||||
useCase?: string[];
|
||||
role: string;
|
||||
orgSize: string;
|
||||
useCase: string;
|
||||
}) => {
|
||||
posthog.capture("onboarding_completed", {
|
||||
role,
|
||||
@@ -122,13 +122,6 @@ export const useTracking = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const trackSaasSelfhostedInquiry = ({ location }: { location: string }) => {
|
||||
posthog.capture("saas_selfhosted_inquiry", {
|
||||
location,
|
||||
...commonProperties,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
trackLoginButtonClick,
|
||||
trackConversationCreated,
|
||||
@@ -141,6 +134,5 @@ export const useTracking = () => {
|
||||
trackCreditLimitReached,
|
||||
trackAddTeamMembersButtonClick,
|
||||
trackOnboardingCompleted,
|
||||
trackSaasSelfhostedInquiry,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -434,8 +434,6 @@ export enum I18nKey {
|
||||
SETTINGS$OPENHANDS_API_KEY_HELP_TEXT = "SETTINGS$OPENHANDS_API_KEY_HELP_TEXT",
|
||||
SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX = "SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX",
|
||||
SETTINGS$LLM_BILLING_INFO = "SETTINGS$LLM_BILLING_INFO",
|
||||
SETTINGS$LLM_ADMIN_INFO = "SETTINGS$LLM_ADMIN_INFO",
|
||||
SETTINGS$LLM_MEMBER_INFO = "SETTINGS$LLM_MEMBER_INFO",
|
||||
SETTINGS$SEE_PRICING_DETAILS = "SETTINGS$SEE_PRICING_DETAILS",
|
||||
SETTINGS$CREATE_API_KEY = "SETTINGS$CREATE_API_KEY",
|
||||
SETTINGS$CREATE_API_KEY_DESCRIPTION = "SETTINGS$CREATE_API_KEY_DESCRIPTION",
|
||||
@@ -1115,40 +1113,31 @@ export enum I18nKey {
|
||||
ORG$NO_MEMBERS_FOUND = "ORG$NO_MEMBERS_FOUND",
|
||||
ORG$NO_MEMBERS_MATCHING_FILTER = "ORG$NO_MEMBERS_MATCHING_FILTER",
|
||||
ORG$FAILED_TO_LOAD_MEMBERS = "ORG$FAILED_TO_LOAD_MEMBERS",
|
||||
ONBOARDING$ORG_NAME_QUESTION = "ONBOARDING$ORG_NAME_QUESTION",
|
||||
ONBOARDING$ORG_NAME_INPUT_NAME = "ONBOARDING$ORG_NAME_INPUT_NAME",
|
||||
ONBOARDING$ORG_NAME_INPUT_DOMAIN = "ONBOARDING$ORG_NAME_INPUT_DOMAIN",
|
||||
ONBOARDING$ORG_SIZE_QUESTION = "ONBOARDING$ORG_SIZE_QUESTION",
|
||||
ONBOARDING$ORG_SIZE_SUBTITLE = "ONBOARDING$ORG_SIZE_SUBTITLE",
|
||||
ONBOARDING$ORG_SIZE_SOLO = "ONBOARDING$ORG_SIZE_SOLO",
|
||||
ONBOARDING$ORG_SIZE_2_10 = "ONBOARDING$ORG_SIZE_2_10",
|
||||
ONBOARDING$ORG_SIZE_11_50 = "ONBOARDING$ORG_SIZE_11_50",
|
||||
ONBOARDING$ORG_SIZE_51_200 = "ONBOARDING$ORG_SIZE_51_200",
|
||||
ONBOARDING$ORG_SIZE_200_PLUS = "ONBOARDING$ORG_SIZE_200_PLUS",
|
||||
ONBOARDING$USE_CASE_QUESTION = "ONBOARDING$USE_CASE_QUESTION",
|
||||
ONBOARDING$USE_CASE_SUBTITLE = "ONBOARDING$USE_CASE_SUBTITLE",
|
||||
ONBOARDING$USE_CASE_NEW_FEATURES = "ONBOARDING$USE_CASE_NEW_FEATURES",
|
||||
ONBOARDING$USE_CASE_APP_FROM_SCRATCH = "ONBOARDING$USE_CASE_APP_FROM_SCRATCH",
|
||||
ONBOARDING$USE_CASE_FIXING_BUGS = "ONBOARDING$USE_CASE_FIXING_BUGS",
|
||||
ONBOARDING$USE_CASE_REFACTORING = "ONBOARDING$USE_CASE_REFACTORING",
|
||||
ONBOARDING$USE_CASE_AUTOMATING_TASKS = "ONBOARDING$USE_CASE_AUTOMATING_TASKS",
|
||||
ONBOARDING$USE_CASE_NOT_SURE = "ONBOARDING$USE_CASE_NOT_SURE",
|
||||
ONBOARDING$ROLE_QUESTION = "ONBOARDING$ROLE_QUESTION",
|
||||
ONBOARDING$ROLE_SOFTWARE_ENGINEER = "ONBOARDING$ROLE_SOFTWARE_ENGINEER",
|
||||
ONBOARDING$ROLE_ENGINEERING_MANAGER = "ONBOARDING$ROLE_ENGINEERING_MANAGER",
|
||||
ONBOARDING$ROLE_CTO_FOUNDER = "ONBOARDING$ROLE_CTO_FOUNDER",
|
||||
ONBOARDING$ROLE_PRODUCT_OPERATIONS = "ONBOARDING$ROLE_PRODUCT_OPERATIONS",
|
||||
ONBOARDING$ROLE_STUDENT_HOBBYIST = "ONBOARDING$ROLE_STUDENT_HOBBYIST",
|
||||
ONBOARDING$ROLE_OTHER = "ONBOARDING$ROLE_OTHER",
|
||||
ONBOARDING$STEP1_TITLE = "ONBOARDING$STEP1_TITLE",
|
||||
ONBOARDING$STEP1_SUBTITLE = "ONBOARDING$STEP1_SUBTITLE",
|
||||
ONBOARDING$SOFTWARE_ENGINEER = "ONBOARDING$SOFTWARE_ENGINEER",
|
||||
ONBOARDING$ENGINEERING_MANAGER = "ONBOARDING$ENGINEERING_MANAGER",
|
||||
ONBOARDING$CTO_FOUNDER = "ONBOARDING$CTO_FOUNDER",
|
||||
ONBOARDING$PRODUCT_OPERATIONS = "ONBOARDING$PRODUCT_OPERATIONS",
|
||||
ONBOARDING$STUDENT_HOBBYIST = "ONBOARDING$STUDENT_HOBBYIST",
|
||||
ONBOARDING$OTHER = "ONBOARDING$OTHER",
|
||||
ONBOARDING$STEP2_TITLE = "ONBOARDING$STEP2_TITLE",
|
||||
ONBOARDING$SOLO = "ONBOARDING$SOLO",
|
||||
ONBOARDING$ORG_2_10 = "ONBOARDING$ORG_2_10",
|
||||
ONBOARDING$ORG_11_50 = "ONBOARDING$ORG_11_50",
|
||||
ONBOARDING$ORG_51_200 = "ONBOARDING$ORG_51_200",
|
||||
ONBOARDING$ORG_200_1000 = "ONBOARDING$ORG_200_1000",
|
||||
ONBOARDING$ORG_1000_PLUS = "ONBOARDING$ORG_1000_PLUS",
|
||||
ONBOARDING$STEP3_TITLE = "ONBOARDING$STEP3_TITLE",
|
||||
ONBOARDING$NEW_FEATURES = "ONBOARDING$NEW_FEATURES",
|
||||
ONBOARDING$APP_FROM_SCRATCH = "ONBOARDING$APP_FROM_SCRATCH",
|
||||
ONBOARDING$FIXING_BUGS = "ONBOARDING$FIXING_BUGS",
|
||||
ONBOARDING$REFACTORING = "ONBOARDING$REFACTORING",
|
||||
ONBOARDING$AUTOMATING_TASKS = "ONBOARDING$AUTOMATING_TASKS",
|
||||
ONBOARDING$NOT_SURE = "ONBOARDING$NOT_SURE",
|
||||
ONBOARDING$NEXT_BUTTON = "ONBOARDING$NEXT_BUTTON",
|
||||
ONBOARDING$BACK_BUTTON = "ONBOARDING$BACK_BUTTON",
|
||||
ONBOARDING$FINISH_BUTTON = "ONBOARDING$FINISH_BUTTON",
|
||||
CTA$ENTERPRISE = "CTA$ENTERPRISE",
|
||||
CTA$ENTERPRISE_DEPLOY = "CTA$ENTERPRISE_DEPLOY",
|
||||
CTA$FEATURE_ON_PREMISES = "CTA$FEATURE_ON_PREMISES",
|
||||
CTA$FEATURE_DATA_CONTROL = "CTA$FEATURE_DATA_CONTROL",
|
||||
CTA$FEATURE_COMPLIANCE = "CTA$FEATURE_COMPLIANCE",
|
||||
CTA$FEATURE_SUPPORT = "CTA$FEATURE_SUPPORT",
|
||||
ENTERPRISE$SELF_HOSTED = "ENTERPRISE$SELF_HOSTED",
|
||||
ENTERPRISE$TITLE = "ENTERPRISE$TITLE",
|
||||
ENTERPRISE$DESCRIPTION = "ENTERPRISE$DESCRIPTION",
|
||||
@@ -1179,7 +1168,4 @@ export enum I18nKey {
|
||||
DEVICE$CONTINUE = "DEVICE$CONTINUE",
|
||||
DEVICE$AUTH_REQUIRED = "DEVICE$AUTH_REQUIRED",
|
||||
DEVICE$SIGN_IN_PROMPT = "DEVICE$SIGN_IN_PROMPT",
|
||||
CTA$ENTERPRISE_TITLE = "CTA$ENTERPRISE_TITLE",
|
||||
CTA$ENTERPRISE_DESCRIPTION = "CTA$ENTERPRISE_DESCRIPTION",
|
||||
CTA$LEARN_MORE = "CTA$LEARN_MORE",
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ export const AvailableLanguages = [
|
||||
{ label: "Italiano", value: "it" },
|
||||
{ label: "Português", value: "pt" },
|
||||
{ label: "Español", value: "es" },
|
||||
{ label: "Català", value: "ca" },
|
||||
{ label: "Türkçe", value: "tr" },
|
||||
{ label: "Українська", value: "uk" },
|
||||
];
|
||||
|
||||
+1409
-2957
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M33.334 3.33398H6.66732C4.82637 3.33398 3.33398 4.82637 3.33398 6.66732V13.334C3.33398 15.1749 4.82637 16.6673 6.66732 16.6673H33.334C35.1749 16.6673 36.6673 15.1749 36.6673 13.334V6.66732C36.6673 4.82637 35.1749 3.33398 33.334 3.33398Z" stroke="#8C8C8C" stroke-width="3.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M33.334 23.334H6.66732C4.82637 23.334 3.33398 24.8264 3.33398 26.6673V33.334C3.33398 35.1749 4.82637 36.6673 6.66732 36.6673H33.334C35.1749 36.6673 36.6673 35.1749 36.6673 33.334V26.6673C36.6673 24.8264 35.1749 23.334 33.334 23.334Z" stroke="#8C8C8C" stroke-width="3.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10 10H10.0167" stroke="#8C8C8C" stroke-width="3.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10 30H10.0167" stroke="#8C8C8C" stroke-width="3.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 999 B |
@@ -5,7 +5,7 @@ import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { EnterpriseBanner } from "#/components/features/device-verify/enterprise-banner";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { H1 } from "#/ui/typography";
|
||||
import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags";
|
||||
import { PROJ_USER_JOURNEY } from "#/utils/feature-flags";
|
||||
|
||||
export default function DeviceVerify() {
|
||||
const { t } = useTranslation();
|
||||
@@ -16,7 +16,7 @@ export default function DeviceVerify() {
|
||||
messageKey: I18nKey;
|
||||
} | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const showEnterpriseBanner = ENABLE_PROJ_USER_JOURNEY();
|
||||
const showEnterpriseBanner = PROJ_USER_JOURNEY();
|
||||
|
||||
// Get user_code from URL parameters
|
||||
const userCode = searchParams.get("user_code");
|
||||
|
||||
@@ -6,25 +6,14 @@ import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestio
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { NewConversation } from "#/components/features/home/new-conversation/new-conversation";
|
||||
import { RecentConversations } from "#/components/features/home/recent-conversations/recent-conversations";
|
||||
import { HomepageCTA } from "#/components/features/home/homepage-cta";
|
||||
import { isCTADismissed } from "#/utils/local-storage";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags";
|
||||
|
||||
<PrefetchPageLinks page="/conversations/:conversationId" />;
|
||||
|
||||
function HomeScreen() {
|
||||
const { data: config } = useConfig();
|
||||
const [selectedRepo, setSelectedRepo] = React.useState<GitRepository | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [shouldShowCTA, setShouldShowCTA] = React.useState(
|
||||
() => !isCTADismissed("homepage"),
|
||||
);
|
||||
|
||||
const isSaasMode = config?.app_mode === "saas";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="home-screen"
|
||||
@@ -51,12 +40,6 @@ function HomeScreen() {
|
||||
<TaskSuggestions filterFor={selectedRepo} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSaasMode && shouldShowCTA && ENABLE_PROJ_USER_JOURNEY() && (
|
||||
<div className="fixed bottom-4 right-8 z-50 md:bottom-6 md:right-12">
|
||||
<HomepageCTA setShouldShowCTA={setShouldShowCTA} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ import { getProviderId } from "#/utils/map-provider";
|
||||
import { DEFAULT_OPENHANDS_MODEL } from "#/utils/verified-models";
|
||||
import { useMe } from "#/hooks/query/use-me";
|
||||
import { usePermission } from "#/hooks/organizations/use-permissions";
|
||||
import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access";
|
||||
|
||||
interface OpenHandsApiKeyHelpProps {
|
||||
testId: string;
|
||||
@@ -75,35 +74,12 @@ function LlmSettingsScreen() {
|
||||
const { data: config } = useConfig();
|
||||
const { data: me } = useMe();
|
||||
const { hasPermission } = usePermission(me?.role ?? "member");
|
||||
const { isPersonalOrg, isTeamOrg } = useOrgTypeAndAccess();
|
||||
|
||||
// In OSS mode, user has full access (no permission restrictions)
|
||||
// In SaaS mode, check role-based permissions (members can only view, owners and admins can edit)
|
||||
const isOssMode = config?.app_mode === "oss";
|
||||
const isReadOnly = isOssMode ? false : !hasPermission("edit_llm_settings");
|
||||
|
||||
// Determine the contextual info message based on workspace type and role
|
||||
const getLlmSettingsInfoMessage = (): I18nKey | null => {
|
||||
// No message in OSS mode (no organization context)
|
||||
if (isOssMode) return null;
|
||||
|
||||
// No message for personal workspaces
|
||||
if (isPersonalOrg) return null;
|
||||
|
||||
// Team org - show appropriate message based on role
|
||||
if (isTeamOrg) {
|
||||
const role = me?.role ?? "member";
|
||||
if (role === "admin" || role === "owner") {
|
||||
return I18nKey.SETTINGS$LLM_ADMIN_INFO;
|
||||
}
|
||||
return I18nKey.SETTINGS$LLM_MEMBER_INFO;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const llmInfoMessage = getLlmSettingsInfoMessage();
|
||||
|
||||
const [view, setView] = React.useState<"basic" | "advanced">("basic");
|
||||
|
||||
const [dirtyInputs, setDirtyInputs] = React.useState({
|
||||
@@ -528,14 +504,6 @@ function LlmSettingsScreen() {
|
||||
className="flex flex-col h-full justify-between"
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
{llmInfoMessage && (
|
||||
<p
|
||||
data-testid="llm-settings-info-message"
|
||||
className="text-sm text-tertiary-alt"
|
||||
>
|
||||
{t(llmInfoMessage)}
|
||||
</p>
|
||||
)}
|
||||
<SettingsSwitch
|
||||
testId="advanced-settings-switch"
|
||||
defaultIsToggled={view === "advanced"}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, redirect } from "react-router";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
import StepHeader from "#/components/features/onboarding/step-header";
|
||||
import { StepContent } from "#/components/features/onboarding/step-content";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
@@ -11,154 +13,159 @@ import { useTracking } from "#/hooks/use-tracking";
|
||||
import { ENABLE_ONBOARDING } from "#/utils/feature-flags";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import {
|
||||
ONBOARDING_FORM,
|
||||
OnboardingQuestion,
|
||||
OnboardingAppMode,
|
||||
} from "#/constants/onboarding";
|
||||
|
||||
export const clientLoader = async () => {
|
||||
if (!ENABLE_ONBOARDING()) {
|
||||
const config = await queryClient.ensureQueryData({
|
||||
queryKey: ["config"],
|
||||
queryFn: OptionService.getConfig,
|
||||
});
|
||||
|
||||
if (config.app_mode !== "saas" || !ENABLE_ONBOARDING()) {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
type OnboardingAnswers = Record<string, string | string[]>;
|
||||
|
||||
function getOnboardingAppMode(): OnboardingAppMode {
|
||||
// TODO: query for app mode (saas or self hosted super user)
|
||||
return "saas";
|
||||
interface StepOption {
|
||||
id: string;
|
||||
labelKey?: I18nKey;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function getAnswerAsArray(answers: OnboardingAnswers, key: string): string[] {
|
||||
const value = answers[key];
|
||||
if (!value) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
interface FormStep {
|
||||
id: string;
|
||||
titleKey: I18nKey;
|
||||
options: StepOption[];
|
||||
}
|
||||
|
||||
function getTranslatedOptions(
|
||||
step: OnboardingQuestion,
|
||||
t: (key: I18nKey) => string,
|
||||
) {
|
||||
if (step.type === "input") return undefined;
|
||||
return step.answerOptions.map((option) => ({
|
||||
id: option.id,
|
||||
label: t(option.key),
|
||||
}));
|
||||
}
|
||||
|
||||
function getTranslatedInputFields(
|
||||
step: OnboardingQuestion,
|
||||
t: (key: I18nKey) => string,
|
||||
) {
|
||||
if (step.type !== "input") return undefined;
|
||||
return step.inputOptions.map((field) => ({
|
||||
id: field.id,
|
||||
label: t(field.key),
|
||||
}));
|
||||
}
|
||||
const steps: FormStep[] = [
|
||||
{
|
||||
id: "step1",
|
||||
titleKey: I18nKey.ONBOARDING$STEP1_TITLE,
|
||||
options: [
|
||||
{
|
||||
id: "software_engineer",
|
||||
labelKey: I18nKey.ONBOARDING$SOFTWARE_ENGINEER,
|
||||
},
|
||||
{
|
||||
id: "engineering_manager",
|
||||
labelKey: I18nKey.ONBOARDING$ENGINEERING_MANAGER,
|
||||
},
|
||||
{
|
||||
id: "cto_founder",
|
||||
labelKey: I18nKey.ONBOARDING$CTO_FOUNDER,
|
||||
},
|
||||
{
|
||||
id: "product_operations",
|
||||
labelKey: I18nKey.ONBOARDING$PRODUCT_OPERATIONS,
|
||||
},
|
||||
{
|
||||
id: "student_hobbyist",
|
||||
labelKey: I18nKey.ONBOARDING$STUDENT_HOBBYIST,
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
labelKey: I18nKey.ONBOARDING$OTHER,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "step2",
|
||||
titleKey: I18nKey.ONBOARDING$STEP2_TITLE,
|
||||
options: [
|
||||
{
|
||||
id: "solo",
|
||||
labelKey: I18nKey.ONBOARDING$SOLO,
|
||||
},
|
||||
{
|
||||
id: "org_2_10",
|
||||
labelKey: I18nKey.ONBOARDING$ORG_2_10,
|
||||
},
|
||||
{
|
||||
id: "org_11_50",
|
||||
labelKey: I18nKey.ONBOARDING$ORG_11_50,
|
||||
},
|
||||
{
|
||||
id: "org_51_200",
|
||||
labelKey: I18nKey.ONBOARDING$ORG_51_200,
|
||||
},
|
||||
{
|
||||
id: "org_200_1000",
|
||||
labelKey: I18nKey.ONBOARDING$ORG_200_1000,
|
||||
},
|
||||
{
|
||||
id: "org_1000_plus",
|
||||
labelKey: I18nKey.ONBOARDING$ORG_1000_PLUS,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "step3",
|
||||
titleKey: I18nKey.ONBOARDING$STEP3_TITLE,
|
||||
options: [
|
||||
{
|
||||
id: "new_features",
|
||||
labelKey: I18nKey.ONBOARDING$NEW_FEATURES,
|
||||
},
|
||||
{
|
||||
id: "app_from_scratch",
|
||||
labelKey: I18nKey.ONBOARDING$APP_FROM_SCRATCH,
|
||||
},
|
||||
{
|
||||
id: "fixing_bugs",
|
||||
labelKey: I18nKey.ONBOARDING$FIXING_BUGS,
|
||||
},
|
||||
{
|
||||
id: "refactoring",
|
||||
labelKey: I18nKey.ONBOARDING$REFACTORING,
|
||||
},
|
||||
{
|
||||
id: "automating_tasks",
|
||||
labelKey: I18nKey.ONBOARDING$AUTOMATING_TASKS,
|
||||
},
|
||||
{
|
||||
id: "not_sure",
|
||||
labelKey: I18nKey.ONBOARDING$NOT_SURE,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function OnboardingForm() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const config = useConfig({ enabled: true });
|
||||
const { mutate: submitOnboarding } = useSubmitOnboarding();
|
||||
const { trackOnboardingCompleted } = useTracking();
|
||||
|
||||
const onboardingAppMode: OnboardingAppMode = getOnboardingAppMode();
|
||||
|
||||
const steps = React.useMemo(
|
||||
() =>
|
||||
ONBOARDING_FORM.filter((step) =>
|
||||
step.app_mode.includes(onboardingAppMode),
|
||||
),
|
||||
[onboardingAppMode],
|
||||
);
|
||||
|
||||
const [currentStepIndex, setCurrentStepIndex] = React.useState(0);
|
||||
const [answers, setAnswers] = React.useState<OnboardingAnswers>({});
|
||||
const [selections, setSelections] = React.useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const currentStep = steps[currentStepIndex];
|
||||
const isLastStep = currentStepIndex === steps.length - 1;
|
||||
const isFirstStep = currentStepIndex === 0;
|
||||
|
||||
const currentSelections = React.useMemo(
|
||||
() => (currentStep ? getAnswerAsArray(answers, currentStep.id) : []),
|
||||
[answers, currentStep],
|
||||
);
|
||||
|
||||
const isStepComplete = React.useMemo(() => {
|
||||
if (!currentStep) return false;
|
||||
|
||||
if (currentStep.type === "input") {
|
||||
return currentStep.inputOptions.every((field) => {
|
||||
const value = answers[field.id];
|
||||
return typeof value === "string" && value.trim() !== "";
|
||||
});
|
||||
}
|
||||
return currentSelections.length > 0;
|
||||
}, [currentStep, answers, currentSelections]);
|
||||
|
||||
const inputValues = React.useMemo(() => {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(answers)) {
|
||||
if (typeof value === "string") {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [answers]);
|
||||
const currentSelection = selections[currentStep.id] || null;
|
||||
|
||||
const handleSelectOption = (optionId: string) => {
|
||||
if (!currentStep) return;
|
||||
|
||||
if (currentStep.type === "multi") {
|
||||
setAnswers((prev) => {
|
||||
const currentArray = getAnswerAsArray(prev, currentStep.id);
|
||||
|
||||
if (currentArray.includes(optionId)) {
|
||||
return {
|
||||
...prev,
|
||||
[currentStep.id]: currentArray.filter((id) => id !== optionId),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[currentStep.id]: [...currentArray, optionId],
|
||||
};
|
||||
});
|
||||
} else {
|
||||
setAnswers((prev) => ({
|
||||
...prev,
|
||||
[currentStep.id]: optionId,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (fieldId: string, value: string) => {
|
||||
setAnswers((prev) => ({
|
||||
setSelections((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: value,
|
||||
[currentStep.id]: optionId,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (isLastStep) {
|
||||
submitOnboarding({ selections: answers });
|
||||
|
||||
// Only track onboarding for SaaS users
|
||||
if (config.data?.app_mode === "saas") {
|
||||
submitOnboarding({ selections });
|
||||
try {
|
||||
trackOnboardingCompleted({
|
||||
role: typeof answers.role === "string" ? answers.role : undefined,
|
||||
orgSize:
|
||||
typeof answers.org_size === "string" ? answers.org_size : undefined,
|
||||
useCase: Array.isArray(answers.use_case)
|
||||
? answers.use_case
|
||||
: undefined,
|
||||
role: selections.step1,
|
||||
orgSize: selections.step2,
|
||||
useCase: selections.step3,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to track onboarding:", error);
|
||||
}
|
||||
} else {
|
||||
setCurrentStepIndex((prev) => prev + 1);
|
||||
@@ -173,12 +180,10 @@ function OnboardingForm() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentStep) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const translatedOptions = getTranslatedOptions(currentStep, t);
|
||||
const translatedInputFields = getTranslatedInputFields(currentStep, t);
|
||||
const translatedOptions = currentStep.options.map((option) => ({
|
||||
id: option.id,
|
||||
label: option.labelKey ? t(option.labelKey) : option.label!,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
@@ -190,20 +195,14 @@ function OnboardingForm() {
|
||||
<OpenHandsLogoWhite width={55} height={55} />
|
||||
</div>
|
||||
<StepHeader
|
||||
title={t(currentStep.questionKey)}
|
||||
subtitle={
|
||||
currentStep.subtitleKey ? t(currentStep.subtitleKey) : undefined
|
||||
}
|
||||
title={t(currentStep.titleKey)}
|
||||
currentStep={currentStepIndex + 1}
|
||||
totalSteps={steps.length}
|
||||
/>
|
||||
<StepContent
|
||||
options={translatedOptions}
|
||||
inputFields={translatedInputFields}
|
||||
selectedOptionIds={currentSelections}
|
||||
inputValues={inputValues}
|
||||
selectedOptionId={currentSelection}
|
||||
onSelectOption={handleSelectOption}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
<div
|
||||
data-testid="step-actions"
|
||||
@@ -223,10 +222,10 @@ function OnboardingForm() {
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleNext}
|
||||
isDisabled={!isStepComplete}
|
||||
isDisabled={!currentSelection}
|
||||
className={cn(
|
||||
"px-4 sm:px-6 py-2.5 bg-white text-black hover:bg-white/90",
|
||||
isFirstStep ? "w-1/2" : "flex-1",
|
||||
isFirstStep ? "w-1/2" : "flex-1", // keep "Next" button to the right. Even if "Back" button is not rendered
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
|
||||
@@ -7,8 +7,6 @@ import { useSharedConversationEvents } from "#/hooks/query/use-shared-conversati
|
||||
import { Messages as V1Messages } from "#/components/v1/chat";
|
||||
import { shouldRenderEvent } from "#/components/v1/chat/event-content-helpers/should-render-event";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { handleEventForUI } from "#/utils/handle-event-for-ui";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
|
||||
|
||||
export default function SharedConversation() {
|
||||
@@ -32,15 +30,9 @@ export default function SharedConversation() {
|
||||
// Transform shared events to V1 format
|
||||
const v1Events = eventsData?.items || [];
|
||||
|
||||
// Reconstruct the same UI event stream used in live conversations so
|
||||
// completed tool calls render as a single action/observation unit.
|
||||
// Filter events that should be rendered
|
||||
const renderableEvents = React.useMemo(
|
||||
() =>
|
||||
v1Events
|
||||
.reduce<
|
||||
OpenHandsEvent[]
|
||||
>((uiEvents, event) => handleEventForUI(event, uiEvents), [])
|
||||
.filter(shouldRenderEvent),
|
||||
() => v1Events.filter(shouldRenderEvent),
|
||||
[v1Events],
|
||||
);
|
||||
|
||||
|
||||
@@ -376,9 +376,3 @@
|
||||
animation: shine 2s linear infinite;
|
||||
background: radial-gradient(circle at center, rgb(24 24 27 / 85%), transparent) -200% 50% / 200% 100% no-repeat, #f4f4f5;
|
||||
}
|
||||
|
||||
/* CTA card gradient and shadow */
|
||||
.cta-card-gradient {
|
||||
background: radial-gradient(85.36% 123.38% at 50% 0%, rgba(255, 255, 255, 0.14) 0%, rgba(0, 0, 0, 0) 100%), #000000;
|
||||
box-shadow: 0px 4px 6px -4px rgba(0, 0, 0, 0.1), 0px 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@@ -56,12 +56,3 @@ export interface OrganizationMembersPage {
|
||||
export type UpdateOrganizationMemberParams = Partial<
|
||||
Omit<OrganizationMember, "org_id" | "user_id">
|
||||
>;
|
||||
|
||||
/**
|
||||
* Query data structure for the organizations query.
|
||||
* This represents the raw data returned by queryClient before any `select` transform.
|
||||
*/
|
||||
export type OrganizationsQueryData = {
|
||||
items: Organization[];
|
||||
currentOrgId: string | null;
|
||||
};
|
||||
|
||||
@@ -58,9 +58,4 @@ export interface ActionEvent<T extends Action = Action> extends BaseEvent {
|
||||
* The LLM's assessment of the safety risk of this action
|
||||
*/
|
||||
security_risk: SecurityRisk;
|
||||
|
||||
/**
|
||||
* Optional LLM-generated summary used to label the tool call in the UI.
|
||||
*/
|
||||
summary?: string | null;
|
||||
}
|
||||
|
||||
+26
-13
@@ -2,30 +2,43 @@ import { ReactNode } from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
const cardVariants = cva("flex", {
|
||||
variants: {
|
||||
theme: {
|
||||
default: "relative bg-[#26282D] border border-[#727987] rounded-xl",
|
||||
outlined: "relative bg-transparent border border-[#727987] rounded-xl",
|
||||
dark: "relative bg-black border border-[#242424] rounded-2xl",
|
||||
const cardVariants = cva(
|
||||
"w-full flex flex-col rounded-[12px] p-[20px] border border-[#727987] bg-[#26282D] relative",
|
||||
{
|
||||
variants: {
|
||||
gap: {
|
||||
default: "gap-[10px]",
|
||||
large: "gap-6",
|
||||
},
|
||||
minHeight: {
|
||||
default: "min-h-[286px] md:min-h-auto",
|
||||
small: "min-h-[263.5px]",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
gap: "default",
|
||||
minHeight: "default",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
theme: "default",
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
interface CardProps extends VariantProps<typeof cardVariants> {
|
||||
children?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function Card({ children, className, testId, theme }: CardProps) {
|
||||
export function Card({
|
||||
children,
|
||||
className = "",
|
||||
testId,
|
||||
gap,
|
||||
minHeight,
|
||||
}: CardProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className={cn(cardVariants({ theme }), className)}
|
||||
className={cn(cardVariants({ gap, minHeight }), className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -2,53 +2,42 @@ import React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
const contextMenuVariants = cva("text-white overflow-hidden z-50", {
|
||||
variants: {
|
||||
theme: {
|
||||
default:
|
||||
"absolute bg-tertiary rounded-[6px] context-menu-box-shadow py-[6px] px-1",
|
||||
naked: "relative",
|
||||
const contextMenuVariants = cva(
|
||||
"absolute bg-tertiary rounded-[6px] text-white overflow-hidden z-50 context-menu-box-shadow",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
compact: "py-1 px-1",
|
||||
default: "py-[6px] px-1",
|
||||
},
|
||||
layout: {
|
||||
vertical: "flex flex-col gap-2",
|
||||
},
|
||||
position: {
|
||||
top: "bottom-full",
|
||||
bottom: "top-full",
|
||||
},
|
||||
spacing: {
|
||||
default: "mt-2",
|
||||
},
|
||||
alignment: {
|
||||
left: "left-0",
|
||||
right: "right-0",
|
||||
},
|
||||
},
|
||||
size: {
|
||||
compact: "py-1 px-1",
|
||||
default: "",
|
||||
},
|
||||
layout: {
|
||||
vertical: "flex flex-col gap-2",
|
||||
},
|
||||
position: {
|
||||
top: "bottom-full",
|
||||
bottom: "top-full",
|
||||
},
|
||||
spacing: {
|
||||
default: "mt-2",
|
||||
none: "",
|
||||
},
|
||||
alignment: {
|
||||
left: "left-0",
|
||||
right: "right-0",
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
layout: "vertical",
|
||||
spacing: "default",
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
theme: "naked",
|
||||
className: "shadow-none",
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
theme: "default",
|
||||
size: "default",
|
||||
layout: "vertical",
|
||||
spacing: "default",
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
interface ContextMenuProps {
|
||||
ref?: React.RefObject<HTMLUListElement | null>;
|
||||
testId?: string;
|
||||
children: React.ReactNode;
|
||||
className?: React.HTMLAttributes<HTMLUListElement>["className"];
|
||||
theme?: VariantProps<typeof contextMenuVariants>["theme"];
|
||||
size?: VariantProps<typeof contextMenuVariants>["size"];
|
||||
layout?: VariantProps<typeof contextMenuVariants>["layout"];
|
||||
position?: VariantProps<typeof contextMenuVariants>["position"];
|
||||
@@ -61,7 +50,6 @@ export function ContextMenu({
|
||||
children,
|
||||
className,
|
||||
ref,
|
||||
theme,
|
||||
size,
|
||||
layout,
|
||||
position,
|
||||
@@ -73,14 +61,7 @@ export function ContextMenu({
|
||||
data-testid={testId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
contextMenuVariants({
|
||||
theme,
|
||||
size,
|
||||
layout,
|
||||
position,
|
||||
spacing,
|
||||
alignment,
|
||||
}),
|
||||
contextMenuVariants({ size, layout, position, spacing, alignment }),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function DropdownMenu({
|
||||
<div
|
||||
className={cn(
|
||||
"absolute z-10 w-full mt-1",
|
||||
"bg-[#1F1F1F] border border-[#242424] rounded-lg",
|
||||
"bg-[#454545] border border-[#727987] rounded-lg",
|
||||
"max-h-60 overflow-auto",
|
||||
!isOpen && "hidden",
|
||||
)}
|
||||
|
||||
@@ -18,7 +18,6 @@ interface DropdownProps {
|
||||
defaultValue?: DropdownOption;
|
||||
onChange?: (item: DropdownOption | null) => void;
|
||||
testId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Dropdown({
|
||||
@@ -31,7 +30,6 @@ export function Dropdown({
|
||||
defaultValue,
|
||||
onChange,
|
||||
testId,
|
||||
className,
|
||||
}: DropdownProps) {
|
||||
const [inputValue, setInputValue] = useState(defaultValue?.label ?? "");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -100,7 +98,6 @@ export function Dropdown({
|
||||
"bg-tertiary border border-[#717888] rounded w-full p-2",
|
||||
"flex items-center gap-2",
|
||||
isDisabled && "cursor-not-allowed opacity-60",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DropdownInput
|
||||
|
||||
@@ -18,7 +18,6 @@ export const VSCODE_IN_NEW_TAB = () => loadFeatureFlag("VSCODE_IN_NEW_TAB");
|
||||
export const ENABLE_TRAJECTORY_REPLAY = () =>
|
||||
loadFeatureFlag("TRAJECTORY_REPLAY");
|
||||
export const ENABLE_ONBOARDING = () => loadFeatureFlag("ENABLE_ONBOARDING");
|
||||
export const ENABLE_PROJ_USER_JOURNEY = () =>
|
||||
loadFeatureFlag("PROJ_USER_JOURNEY");
|
||||
export const ENABLE_SANDBOX_GROUPING = () =>
|
||||
loadFeatureFlag("SANDBOX_GROUPING");
|
||||
export const PROJ_USER_JOURNEY = () => loadFeatureFlag("PROJ_USER_JOURNEY");
|
||||
|
||||
@@ -36,26 +36,3 @@ export const getLoginMethod = (): LoginMethod | null => {
|
||||
export const clearLoginData = (): void => {
|
||||
localStorage.removeItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD);
|
||||
};
|
||||
|
||||
// CTA locations that can be dismissed
|
||||
export type CTALocation = "homepage";
|
||||
|
||||
// Generate storage key for a CTA location
|
||||
const getCTAKey = (location: CTALocation): string =>
|
||||
`${location}-cta-dismissed`;
|
||||
|
||||
/**
|
||||
* Set a CTA as dismissed in local storage (persists across tabs)
|
||||
* @param location The CTA location to dismiss
|
||||
*/
|
||||
export const setCTADismissed = (location: CTALocation): void => {
|
||||
localStorage.setItem(getCTAKey(location), "true");
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a CTA has been dismissed
|
||||
* @param location The CTA location to check
|
||||
* @returns true if dismissed, false otherwise
|
||||
*/
|
||||
export const isCTADismissed = (location: CTALocation): boolean =>
|
||||
localStorage.getItem(getCTAKey(location)) === "true";
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { organizationService } from "#/api/organization-service/organization-service.api";
|
||||
import { getSelectedOrganizationIdFromStore } from "#/stores/selected-organization-store";
|
||||
import {
|
||||
OrganizationMember,
|
||||
OrganizationsQueryData,
|
||||
OrganizationUserRole,
|
||||
} from "#/types/org";
|
||||
import { OrganizationMember, OrganizationUserRole } from "#/types/org";
|
||||
import { PermissionKey } from "./permissions";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
|
||||
@@ -12,45 +8,12 @@ import { queryClient } from "#/query-client-config";
|
||||
* Get the active organization user.
|
||||
* Uses React Query's fetchQuery to leverage request deduplication,
|
||||
* preventing duplicate API calls when multiple consumers request the same data.
|
||||
*
|
||||
* On page refresh, the Zustand store resets and orgId becomes null.
|
||||
* In this case, we retrieve the organization from the query cache or fetch it
|
||||
* from the backend to ensure permission checks work correctly.
|
||||
*
|
||||
* @returns OrganizationMember
|
||||
*/
|
||||
export const getActiveOrganizationUser = async (): Promise<
|
||||
OrganizationMember | undefined
|
||||
> => {
|
||||
let orgId = getSelectedOrganizationIdFromStore();
|
||||
|
||||
// If no orgId in store (e.g., after page refresh), try to get it from query cache or fetch
|
||||
if (!orgId) {
|
||||
// Check if organizations data is already in the cache
|
||||
let organizationsData = queryClient.getQueryData<OrganizationsQueryData>([
|
||||
"organizations",
|
||||
]);
|
||||
|
||||
// If not in cache, fetch it
|
||||
if (!organizationsData) {
|
||||
try {
|
||||
organizationsData = await queryClient.fetchQuery({
|
||||
queryKey: ["organizations"],
|
||||
queryFn: organizationService.getOrganizations,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes - matches useOrganizations hook
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Use currentOrgId from backend (user's last selected org) or first org as fallback
|
||||
orgId =
|
||||
organizationsData?.currentOrgId ??
|
||||
organizationsData?.items?.[0]?.id ??
|
||||
null;
|
||||
}
|
||||
|
||||
const orgId = getSelectedOrganizationIdFromStore();
|
||||
if (!orgId) return undefined;
|
||||
|
||||
try {
|
||||
|
||||
@@ -838,7 +838,7 @@ interface GetStatusTextArgs {
|
||||
* isStartingStatus: false,
|
||||
* isStopStatus: false,
|
||||
* curAgentState: AgentState.RUNNING
|
||||
* }) // Returns "Waiting for sandbox"
|
||||
* }) // Returns "Waiting For Sandbox"
|
||||
*/
|
||||
export function getStatusText({
|
||||
isPausing = false,
|
||||
@@ -866,13 +866,13 @@ export function getStatusText({
|
||||
return t(I18nKey.CONVERSATION$READY);
|
||||
}
|
||||
|
||||
// Format status text with sentence case: "WAITING_FOR_SANDBOX" -> "Waiting for sandbox"
|
||||
// Format status text: "WAITING_FOR_SANDBOX" -> "Waiting for sandbox"
|
||||
return (
|
||||
taskDetail ||
|
||||
taskStatus
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/^\w/, (c) => c.toUpperCase())
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ async def _get_agent_server_context(
|
||||
app_conversation_service: AppConversationService,
|
||||
sandbox_service: SandboxService,
|
||||
sandbox_spec_service: SandboxSpecService,
|
||||
) -> AgentServerContext | JSONResponse | None:
|
||||
) -> AgentServerContext | JSONResponse:
|
||||
"""Get the agent server context for a conversation.
|
||||
|
||||
This helper retrieves all necessary information to communicate with the
|
||||
@@ -129,8 +129,7 @@ async def _get_agent_server_context(
|
||||
sandbox_spec_service: Service for sandbox spec operations
|
||||
|
||||
Returns:
|
||||
AgentServerContext if successful, JSONResponse(404) if conversation
|
||||
not found, or None if sandbox is not running (e.g. closed conversation).
|
||||
AgentServerContext if successful, or JSONResponse with error details.
|
||||
"""
|
||||
# Get the conversation info
|
||||
conversation = await app_conversation_service.get_app_conversation(conversation_id)
|
||||
@@ -142,19 +141,12 @@ async def _get_agent_server_context(
|
||||
|
||||
# Get the sandbox info
|
||||
sandbox = await sandbox_service.get_sandbox(conversation.sandbox_id)
|
||||
if not sandbox:
|
||||
if not sandbox or sandbox.status != SandboxStatus.RUNNING:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': f'Sandbox not found for conversation {conversation_id}'},
|
||||
)
|
||||
# Return None for paused sandboxes (closed conversation)
|
||||
if sandbox.status == SandboxStatus.PAUSED:
|
||||
return None
|
||||
# Return 404 for other non-running states (STARTING, ERROR, MISSING)
|
||||
if sandbox.status != SandboxStatus.RUNNING:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': f'Sandbox not ready for conversation {conversation_id}'},
|
||||
content={
|
||||
'error': f'Sandbox not found or not running for conversation {conversation_id}'
|
||||
},
|
||||
)
|
||||
|
||||
# Get the sandbox spec to find the working directory
|
||||
@@ -595,7 +587,6 @@ async def get_conversation_skills(
|
||||
|
||||
Returns:
|
||||
JSONResponse: A JSON response containing the list of skills.
|
||||
Returns an empty list if the sandbox is not running.
|
||||
"""
|
||||
try:
|
||||
# Get agent server context (conversation, sandbox, sandbox_spec, agent_server_url)
|
||||
@@ -607,8 +598,6 @@ async def get_conversation_skills(
|
||||
)
|
||||
if isinstance(ctx, JSONResponse):
|
||||
return ctx
|
||||
if ctx is None:
|
||||
return JSONResponse(status_code=status.HTTP_200_OK, content={'skills': []})
|
||||
|
||||
# Load skills from all sources
|
||||
logger.info(f'Loading skills for conversation {conversation_id}')
|
||||
@@ -696,7 +685,6 @@ async def get_conversation_hooks(
|
||||
|
||||
Returns:
|
||||
JSONResponse: A JSON response containing the list of hook event types.
|
||||
Returns an empty list if the sandbox is not running.
|
||||
"""
|
||||
try:
|
||||
# Get agent server context (conversation, sandbox, sandbox_spec, agent_server_url)
|
||||
@@ -708,8 +696,6 @@ async def get_conversation_hooks(
|
||||
)
|
||||
if isinstance(ctx, JSONResponse):
|
||||
return ctx
|
||||
if ctx is None:
|
||||
return JSONResponse(status_code=status.HTTP_200_OK, content={'hooks': []})
|
||||
|
||||
from openhands.app_server.app_conversation.hook_loader import (
|
||||
fetch_hooks_from_agent_server,
|
||||
|
||||
@@ -43,8 +43,8 @@ from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
|
||||
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
PRE_COMMIT_HOOK = '.git/hooks/pre-commit'
|
||||
PRE_COMMIT_LOCAL = '.git/hooks/pre-commit.local'
|
||||
PRE_COMMIT_HOOK = '/.git/hooks/pre-commit'
|
||||
PRE_COMMIT_LOCAL = '/.git/hooks/pre-commit.local'
|
||||
|
||||
|
||||
def get_project_dir(
|
||||
@@ -405,7 +405,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
|
||||
# Check if there's an existing pre-commit hook
|
||||
with tempfile.TemporaryFile(mode='w+t') as temp_file:
|
||||
result = await workspace.file_download(PRE_COMMIT_HOOK, str(temp_file))
|
||||
if result.success:
|
||||
if result.get('success'):
|
||||
_logger.info('Preserving existing pre-commit hook')
|
||||
# an existing pre-commit hook exists
|
||||
if 'This hook was installed by OpenHands' not in temp_file.read():
|
||||
|
||||
@@ -33,6 +33,21 @@ class ExposedUrlConfig(BaseModel):
|
||||
port: int
|
||||
|
||||
|
||||
WORK_HOSTS_SKILL_FOOTER = """
|
||||
When starting a web server, use the corresponding ports via environment variables:
|
||||
- $WORKER_1 for the first port
|
||||
- $WORKER_2 for the second port
|
||||
|
||||
**CRITICAL: You MUST enable CORS and bind to 0.0.0.0.** Without CORS headers, the App tab cannot detect your server and will show an empty state.
|
||||
|
||||
Example (Flask):
|
||||
```python
|
||||
from flask_cors import CORS
|
||||
CORS(app)
|
||||
app.run(host='0.0.0.0', port=int(os.environ.get('WORKER_1', 12000)))
|
||||
```"""
|
||||
|
||||
|
||||
class SandboxConfig(BaseModel):
|
||||
"""Sandbox configuration for agent-server API request."""
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationInfo,
|
||||
)
|
||||
@@ -25,9 +22,6 @@ from openhands.sdk import Event, MessageEvent
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Poll with ~3.75s total wait per message event before retrying later.
|
||||
_TITLE_POLL_DELAYS_S = (0.25, 0.5, 1.0, 2.0)
|
||||
|
||||
|
||||
class SetTitleCallbackProcessor(EventCallbackProcessor):
|
||||
"""Callback processor which sets conversation titles."""
|
||||
@@ -57,6 +51,7 @@ class SetTitleCallbackProcessor(EventCallbackProcessor):
|
||||
get_app_conversation_info_service(state) as app_conversation_info_service,
|
||||
get_httpx_client(state) as httpx_client,
|
||||
):
|
||||
# Generate a title for the conversation
|
||||
app_conversation = await app_conversation_service.get_app_conversation(
|
||||
conversation_id
|
||||
)
|
||||
@@ -66,38 +61,15 @@ class SetTitleCallbackProcessor(EventCallbackProcessor):
|
||||
app_conversation_url = replace_localhost_hostname_for_docker(
|
||||
app_conversation_url
|
||||
)
|
||||
|
||||
title = None
|
||||
for delay_s in _TITLE_POLL_DELAYS_S:
|
||||
try:
|
||||
response = await httpx_client.get(
|
||||
app_conversation_url,
|
||||
headers={
|
||||
'X-Session-API-Key': app_conversation.session_api_key,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
# Transient agent-server failures are acceptable; retry later.
|
||||
_logger.debug(
|
||||
'Title poll failed for conversation %s: %s',
|
||||
conversation_id,
|
||||
exc,
|
||||
)
|
||||
else:
|
||||
title = response.json().get('title')
|
||||
if title:
|
||||
break
|
||||
# Backoff applies to both missing-title responses and transient errors.
|
||||
await asyncio.sleep(delay_s)
|
||||
|
||||
if not title:
|
||||
# Keep the callback active so later message events can retry.
|
||||
_logger.info(
|
||||
f'Conversation {conversation_id} title not available yet; '
|
||||
'will retry on a future message event.'
|
||||
)
|
||||
return None
|
||||
response = await httpx_client.post(
|
||||
f'{app_conversation_url}/generate_title',
|
||||
headers={
|
||||
'X-Session-API-Key': app_conversation.session_api_key,
|
||||
},
|
||||
content='{}',
|
||||
)
|
||||
response.raise_for_status()
|
||||
title = response.json()['title']
|
||||
|
||||
# Save the conversation info
|
||||
info = AppConversationInfo(
|
||||
|
||||
@@ -197,12 +197,6 @@ class DockerSandboxService(SandboxService):
|
||||
)
|
||||
)
|
||||
|
||||
if not container.image.tags:
|
||||
_logger.debug(
|
||||
f'Skipping container {container.name!r}: image has no tags (image id: {container.image.id})'
|
||||
)
|
||||
return None
|
||||
|
||||
return SandboxInfo(
|
||||
id=container.name,
|
||||
created_by_user_id=None,
|
||||
|
||||
@@ -471,7 +471,7 @@ class ProviderHandler:
|
||||
def check_cmd_action_for_provider_token_ref(
|
||||
cls, event: Action
|
||||
) -> list[ProviderType]:
|
||||
"""Detect if agent run action is using a provider token (e.g github_token)
|
||||
"""Detect if agent run action is using a provider token (e.g $GITHUB_TOKEN)
|
||||
Returns a list of providers which are called by the agent
|
||||
"""
|
||||
if not isinstance(event, CmdRunAction):
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ReviewThread(BaseModel):
|
||||
@@ -21,13 +21,7 @@ class Issue(BaseModel):
|
||||
repo: str
|
||||
number: int
|
||||
title: str
|
||||
body: str = ''
|
||||
|
||||
@field_validator('body', mode='before')
|
||||
@classmethod
|
||||
def body_must_not_be_none(cls, v: str | None) -> str:
|
||||
return v if v is not None else ''
|
||||
|
||||
body: str
|
||||
thread_comments: list[str] | None = None # Added field for issue thread comments
|
||||
closing_issues: list[str] | None = None
|
||||
review_comments: list[str] | None = None
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
@@ -72,16 +71,15 @@ class InvariantAnalyzer(SecurityAnalyzer):
|
||||
else:
|
||||
self.container = running_containers[0]
|
||||
|
||||
start_time = time.time()
|
||||
elapsed = 0
|
||||
while self.container.status != 'running':
|
||||
self.container = self.docker_client.containers.get(self.container_name)
|
||||
elapsed = time.time() - start_time
|
||||
elapsed += 1
|
||||
logger.debug(
|
||||
f'waiting for container to start: {elapsed:.1f}s, container status: {self.container.status}'
|
||||
f'waiting for container to start: {elapsed}, container status: {self.container.status}'
|
||||
)
|
||||
if elapsed > self.timeout:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
|
||||
self.api_port = int(
|
||||
self.container.attrs['NetworkSettings']['Ports']['8000/tcp'][0]['HostPort']
|
||||
|
||||
Generated
+6
-6
@@ -5496,14 +5496,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.26.0"
|
||||
version = "1.25.0"
|
||||
description = "Model Context Protocol SDK"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca"},
|
||||
{file = "mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66"},
|
||||
{file = "mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a"},
|
||||
{file = "mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -7589,14 +7589,14 @@ wrappers-encryption = ["cryptography (>=45.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.3"
|
||||
version = "0.6.2"
|
||||
description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"},
|
||||
{file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"},
|
||||
{file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"},
|
||||
{file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -263,7 +263,7 @@ class TestGetConversationHooks:
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
async def test_get_hooks_returns_404_when_sandbox_not_found(self):
|
||||
async def test_get_hooks_returns_404_when_sandbox_not_running(self):
|
||||
conversation_id = uuid4()
|
||||
sandbox_id = str(uuid4())
|
||||
|
||||
@@ -291,44 +291,3 @@ class TestGetConversationHooks:
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
async def test_get_hooks_returns_empty_list_when_sandbox_paused(self):
|
||||
conversation_id = uuid4()
|
||||
sandbox_id = str(uuid4())
|
||||
|
||||
mock_conversation = AppConversation(
|
||||
id=conversation_id,
|
||||
created_by_user_id='test-user',
|
||||
sandbox_id=sandbox_id,
|
||||
sandbox_status=SandboxStatus.PAUSED,
|
||||
)
|
||||
|
||||
mock_sandbox = SandboxInfo(
|
||||
id=sandbox_id,
|
||||
created_by_user_id='test-user',
|
||||
status=SandboxStatus.PAUSED,
|
||||
sandbox_spec_id=str(uuid4()),
|
||||
session_api_key='test-api-key',
|
||||
)
|
||||
|
||||
mock_app_conversation_service = MagicMock()
|
||||
mock_app_conversation_service.get_app_conversation = AsyncMock(
|
||||
return_value=mock_conversation
|
||||
)
|
||||
|
||||
mock_sandbox_service = MagicMock()
|
||||
mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox)
|
||||
|
||||
response = await get_conversation_hooks(
|
||||
conversation_id=conversation_id,
|
||||
app_conversation_service=mock_app_conversation_service,
|
||||
sandbox_service=mock_sandbox_service,
|
||||
sandbox_spec_service=MagicMock(),
|
||||
httpx_client=AsyncMock(spec=httpx.AsyncClient),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
import json
|
||||
|
||||
data = json.loads(response.body.decode('utf-8'))
|
||||
assert data == {'hooks': []}
|
||||
|
||||
@@ -203,7 +203,7 @@ class TestGetConversationSkills:
|
||||
|
||||
Arrange: Setup conversation but no sandbox
|
||||
Act: Call get_conversation_skills endpoint
|
||||
Assert: Response is 404
|
||||
Assert: Response is 404 with sandbox error message
|
||||
"""
|
||||
# Arrange
|
||||
conversation_id = uuid4()
|
||||
@@ -237,13 +237,19 @@ class TestGetConversationSkills:
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
content = response.body.decode('utf-8')
|
||||
import json
|
||||
|
||||
async def test_get_skills_returns_empty_list_when_sandbox_paused(self):
|
||||
"""Test endpoint returns empty skills when sandbox is PAUSED (closed conversation).
|
||||
data = json.loads(content)
|
||||
assert 'error' in data
|
||||
assert 'Sandbox not found' in data['error']
|
||||
|
||||
Arrange: Setup conversation with paused sandbox
|
||||
async def test_get_skills_returns_404_when_sandbox_not_running(self):
|
||||
"""Test endpoint returns 404 when sandbox is not in RUNNING state.
|
||||
|
||||
Arrange: Setup conversation with stopped sandbox
|
||||
Act: Call get_conversation_skills endpoint
|
||||
Assert: Response is 200 with empty skills list
|
||||
Assert: Response is 404 with sandbox not running message
|
||||
"""
|
||||
# Arrange
|
||||
conversation_id = uuid4()
|
||||
@@ -284,12 +290,13 @@ class TestGetConversationSkills:
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
content = response.body.decode('utf-8')
|
||||
import json
|
||||
|
||||
data = json.loads(content)
|
||||
assert data == {'skills': []}
|
||||
assert 'error' in data
|
||||
assert 'not running' in data['error']
|
||||
|
||||
async def test_get_skills_handles_task_trigger_skills(self):
|
||||
"""Test endpoint correctly handles skills with TaskTrigger.
|
||||
|
||||
@@ -245,61 +245,6 @@ class TestDockerSandboxService:
|
||||
assert len(result.items) == 0
|
||||
assert result.next_page_id is None
|
||||
|
||||
async def test_search_sandboxes_skips_containers_with_no_image_tags(
|
||||
self, service, mock_running_container
|
||||
):
|
||||
"""Test that containers with tagless images are skipped without crashing.
|
||||
|
||||
Regression test: when a container's image has been rebuilt with the same tag,
|
||||
the old container's image loses its tags, causing container.image.tags to be
|
||||
an empty list. Previously this caused an IndexError.
|
||||
"""
|
||||
# Setup a container with no image tags (e.g. image was retagged/rebuilt)
|
||||
tagless_container = MagicMock()
|
||||
tagless_container.name = 'oh-test-tagless'
|
||||
tagless_container.status = 'paused'
|
||||
tagless_container.image.tags = []
|
||||
tagless_container.image.id = 'sha256:abc123def456'
|
||||
tagless_container.attrs = {
|
||||
'Created': '2024-01-15T10:30:00.000000000Z',
|
||||
'Config': {'Env': []},
|
||||
'NetworkSettings': {'Ports': {}},
|
||||
}
|
||||
|
||||
service.docker_client.containers.list.return_value = [
|
||||
mock_running_container,
|
||||
tagless_container,
|
||||
]
|
||||
service.httpx_client.get.return_value.raise_for_status.return_value = None
|
||||
|
||||
# Execute - should not raise IndexError
|
||||
result = await service.search_sandboxes()
|
||||
|
||||
# Verify - only the properly tagged container is returned
|
||||
assert isinstance(result, SandboxPage)
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0].id == 'oh-test-abc123'
|
||||
|
||||
async def test_get_sandbox_returns_none_for_tagless_image(self, service):
|
||||
"""Test that get_sandbox returns None for containers with tagless images."""
|
||||
tagless_container = MagicMock()
|
||||
tagless_container.name = 'oh-test-tagless'
|
||||
tagless_container.status = 'paused'
|
||||
tagless_container.image.tags = []
|
||||
tagless_container.image.id = 'sha256:abc123def456'
|
||||
tagless_container.attrs = {
|
||||
'Created': '2024-01-15T10:30:00.000000000Z',
|
||||
'Config': {'Env': []},
|
||||
'NetworkSettings': {'Ports': {}},
|
||||
}
|
||||
service.docker_client.containers.get.return_value = tagless_container
|
||||
|
||||
# Execute - should not raise IndexError
|
||||
result = await service.get_sandbox('oh-test-tagless')
|
||||
|
||||
# Verify - returns None for tagless container
|
||||
assert result is None
|
||||
|
||||
async def test_search_sandboxes_filters_by_prefix(self, service):
|
||||
"""Test that search filters containers by name prefix."""
|
||||
# Setup
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user