Compare commits

..

1 Commits

Author SHA1 Message Date
Nik
6dbf26ca04 feat(chat): add DB schema and Pydantic models for multi-model answers - 1/8
Add the foundational schema and type changes needed for multi-model
answer generation, where users can compare responses from up to 3 LLMs
side-by-side.

- Alembic migration: add `preferred_response_id` FK and
  `model_display_name` columns to `chat_message`
- Extend `SendMessageRequest` with `llm_overrides: list[LLMOverride]`
- Add `model_index` to `Placement` for streaming packet routing
- New `MultiModelMessageResponseIDInfo` packet type
- Extend `ChatMessageDetail` with `preferred_response_id` and
  `model_display_name`
2026-03-12 12:21:23 -07:00
84 changed files with 1244 additions and 3573 deletions

View File

@@ -31,6 +31,7 @@ jobs:
is-cloud-tag: ${{ steps.check.outputs.is-cloud-tag }}
is-beta: ${{ steps.check.outputs.is-beta }}
is-beta-standalone: ${{ steps.check.outputs.is-beta-standalone }}
is-craft-latest: ${{ steps.check.outputs.is-craft-latest }}
is-latest: ${{ steps.check.outputs.is-latest }}
is-test-run: ${{ steps.check.outputs.is-test-run }}
sanitized-tag: ${{ steps.check.outputs.sanitized-tag }}
@@ -54,7 +55,6 @@ jobs:
env:
EVENT_NAME: ${{ github.event_name }}
run: |
set -eo pipefail
TAG="${GITHUB_REF_NAME}"
# Sanitize tag name by replacing slashes with hyphens (for Docker tag compatibility)
SANITIZED_TAG=$(echo "$TAG" | tr '/' '-')
@@ -67,6 +67,7 @@ jobs:
IS_STABLE=false
IS_BETA=false
IS_BETA_STANDALONE=false
IS_CRAFT_LATEST=false
IS_LATEST=false
IS_PROD_TAG=false
IS_TEST_RUN=false
@@ -78,6 +79,9 @@ jobs:
BUILD_MODEL_SERVER=true
# Determine tag type based on pattern matching (do regex checks once)
if [[ "$TAG" == craft-* ]]; then
IS_CRAFT_LATEST=true
fi
if [[ "$TAG" == *cloud* ]]; then
IS_CLOUD=true
fi
@@ -105,6 +109,12 @@ jobs:
fi
fi
# Craft-latest builds backend with Craft enabled
if [[ "$IS_CRAFT_LATEST" == "true" ]]; then
BUILD_BACKEND_CRAFT=true
BUILD_BACKEND=false
fi
# Standalone version checks (for backend/model-server - version excluding cloud tags)
if [[ "$IS_BETA" == "true" ]] && [[ "$IS_CLOUD" != "true" ]]; then
IS_BETA_STANDALONE=true
@@ -122,11 +132,6 @@ jobs:
fi
fi
# Build craft-latest backend alongside the regular latest.
if [[ "$IS_LATEST" == "true" ]]; then
BUILD_BACKEND_CRAFT=true
fi
# Determine if this is a production tag
# Production tags are: version tags (v1.2.3*) or nightly tags
if [[ "$IS_VERSION_TAG" == "true" ]] || [[ "$IS_NIGHTLY" == "true" ]]; then
@@ -147,6 +152,7 @@ jobs:
echo "is-cloud-tag=$IS_CLOUD"
echo "is-beta=$IS_BETA"
echo "is-beta-standalone=$IS_BETA_STANDALONE"
echo "is-craft-latest=$IS_CRAFT_LATEST"
echo "is-latest=$IS_LATEST"
echo "is-test-run=$IS_TEST_RUN"
echo "sanitized-tag=$SANITIZED_TAG"

View File

@@ -1,102 +1,67 @@
name: Post-Merge Beta Cherry-Pick
on:
pull_request_target:
types:
- closed
push:
branches:
- main
# SECURITY NOTE:
# This workflow intentionally uses pull_request_target so post-merge automation can
# use base-repo credentials. Do not checkout PR head refs in this workflow
# (e.g. github.event.pull_request.head.sha). Only trusted base refs are allowed.
permissions:
contents: read
jobs:
resolve-cherry-pick-request:
if: >-
github.event.pull_request.merged == true
&& github.event.pull_request.base.ref == 'main'
&& github.event.pull_request.head.repo.full_name == github.repository
outputs:
should_cherrypick: ${{ steps.gate.outputs.should_cherrypick }}
pr_number: ${{ steps.gate.outputs.pr_number }}
merge_commit_sha: ${{ steps.gate.outputs.merge_commit_sha }}
merged_by: ${{ steps.gate.outputs.merged_by }}
gate_error: ${{ steps.gate.outputs.gate_error }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Resolve merged PR and checkbox state
id: gate
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
# SECURITY: keep PR body in env/plain-text handling; avoid directly
# inlining github.event.pull_request.body into shell commands.
PR_BODY: ${{ github.event.pull_request.body }}
MERGE_COMMIT_SHA: ${{ github.event.pull_request.merge_commit_sha }}
MERGED_BY: ${{ github.event.pull_request.merged_by.login }}
# GitHub team slug authorized to trigger cherry-picks (e.g. "core-eng").
# For private/secret teams the GITHUB_TOKEN may need org:read scope;
# visible teams work with the default token.
ALLOWED_TEAM: "onyx-core-team"
run: |
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
echo "merged_by=${MERGED_BY}" >> "$GITHUB_OUTPUT"
if ! echo "${PR_BODY}" | grep -qiE "\\[x\\][[:space:]]*(\\[[^]]+\\][[:space:]]*)?Please cherry-pick this PR to the latest release version"; then
echo "should_cherrypick=false" >> "$GITHUB_OUTPUT"
echo "Cherry-pick checkbox not checked for PR #${PR_NUMBER}. Skipping."
exit 0
fi
# Keep should_cherrypick output before any possible exit 1 below so
# notify-slack can still gate on this output even if this job fails.
echo "should_cherrypick=true" >> "$GITHUB_OUTPUT"
echo "Cherry-pick checkbox checked for PR #${PR_NUMBER}."
if [ -z "${MERGE_COMMIT_SHA}" ] || [ "${MERGE_COMMIT_SHA}" = "null" ]; then
echo "gate_error=missing-merge-commit-sha" >> "$GITHUB_OUTPUT"
echo "::error::PR #${PR_NUMBER} requested cherry-pick, but merge_commit_sha is missing."
exit 1
fi
echo "merge_commit_sha=${MERGE_COMMIT_SHA}" >> "$GITHUB_OUTPUT"
member_state_file="$(mktemp)"
member_err_file="$(mktemp)"
if ! gh api "orgs/${GITHUB_REPOSITORY_OWNER}/teams/${ALLOWED_TEAM}/memberships/${MERGED_BY}" --jq '.state' >"${member_state_file}" 2>"${member_err_file}"; then
api_err="$(tr '\n' ' ' < "${member_err_file}" | sed 's/[[:space:]]\+/ /g' | cut -c1-300)"
echo "gate_error=team-api-error" >> "$GITHUB_OUTPUT"
echo "::error::Team membership API call failed for ${MERGED_BY} in ${ALLOWED_TEAM}: ${api_err}"
exit 1
fi
member_state="$(cat "${member_state_file}")"
if [ "${member_state}" != "active" ]; then
echo "gate_error=not-team-member" >> "$GITHUB_OUTPUT"
echo "::error::${MERGED_BY} is not an active member of team ${ALLOWED_TEAM} (state: ${member_state}). Failing cherry-pick gate."
exit 1
fi
exit 0
cherry-pick-to-latest-release:
needs:
- resolve-cherry-pick-request
if: needs.resolve-cherry-pick-request.outputs.should_cherrypick == 'true' && needs.resolve-cherry-pick-request.result == 'success'
permissions:
contents: write
pull-requests: write
outputs:
should_cherrypick: ${{ steps.gate.outputs.should_cherrypick }}
pr_number: ${{ steps.gate.outputs.pr_number }}
cherry_pick_reason: ${{ steps.run_cherry_pick.outputs.reason }}
cherry_pick_details: ${{ steps.run_cherry_pick.outputs.details }}
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Resolve merged PR and checkbox state
id: gate
env:
GH_TOKEN: ${{ github.token }}
run: |
# For the commit that triggered this workflow (HEAD on main), fetch all
# associated PRs and keep only the PR that was actually merged into main
# with this exact merge commit SHA.
pr_numbers="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls" | jq -r --arg sha "${GITHUB_SHA}" '.[] | select(.merged_at != null and .base.ref == "main" and .merge_commit_sha == $sha) | .number')"
match_count="$(printf '%s\n' "$pr_numbers" | sed '/^[[:space:]]*$/d' | wc -l | tr -d ' ')"
pr_number="$(printf '%s\n' "$pr_numbers" | sed '/^[[:space:]]*$/d' | head -n 1)"
if [ "${match_count}" -gt 1 ]; then
echo "::warning::Multiple merged PRs matched commit ${GITHUB_SHA}. Using PR #${pr_number}."
fi
if [ -z "$pr_number" ]; then
echo "No merged PR associated with commit ${GITHUB_SHA}; skipping."
echo "should_cherrypick=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Read the PR once so we can gate behavior and infer preferred actor.
pr_json="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}")"
pr_body="$(printf '%s' "$pr_json" | jq -r '.body // ""')"
merged_by="$(printf '%s' "$pr_json" | jq -r '.merged_by.login // ""')"
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
echo "merged_by=$merged_by" >> "$GITHUB_OUTPUT"
if echo "$pr_body" | grep -qiE "\\[x\\][[:space:]]*(\\[[^]]+\\][[:space:]]*)?Please cherry-pick this PR to the latest release version"; then
echo "should_cherrypick=true" >> "$GITHUB_OUTPUT"
echo "Cherry-pick checkbox checked for PR #${pr_number}."
exit 0
fi
echo "should_cherrypick=false" >> "$GITHUB_OUTPUT"
echo "Cherry-pick checkbox not checked for PR #${pr_number}. Skipping."
- name: Checkout repository
# SECURITY: keep checkout pinned to trusted base branch; do not switch to PR head refs.
if: steps.gate.outputs.should_cherrypick == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
with:
fetch-depth: 0
@@ -104,37 +69,31 @@ jobs:
ref: main
- name: Install the latest version of uv
if: steps.gate.outputs.should_cherrypick == 'true'
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # ratchet:astral-sh/setup-uv@v7
with:
enable-cache: false
version: "0.9.9"
- name: Configure git identity
if: steps.gate.outputs.should_cherrypick == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Create cherry-pick PR to latest release
id: run_cherry_pick
if: steps.gate.outputs.should_cherrypick == 'true'
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
CHERRY_PICK_ASSIGNEE: ${{ needs.resolve-cherry-pick-request.outputs.merged_by }}
MERGE_COMMIT_SHA: ${{ needs.resolve-cherry-pick-request.outputs.merge_commit_sha }}
CHERRY_PICK_ASSIGNEE: ${{ steps.gate.outputs.merged_by }}
run: |
set -o pipefail
output_file="$(mktemp)"
set +e
uv run --no-sync --with onyx-devtools ods cherry-pick "${MERGE_COMMIT_SHA}" --yes --no-verify 2>&1 | tee "$output_file"
pipe_statuses=("${PIPESTATUS[@]}")
exit_code="${pipe_statuses[0]}"
tee_exit="${pipe_statuses[1]:-0}"
set -e
if [ "${tee_exit}" -ne 0 ]; then
echo "status=failure" >> "$GITHUB_OUTPUT"
echo "reason=output-capture-failed" >> "$GITHUB_OUTPUT"
echo "::error::tee failed to capture cherry-pick output (exit ${tee_exit}); cannot classify result."
exit 1
fi
uv run --no-sync --with onyx-devtools ods cherry-pick "${GITHUB_SHA}" --yes --no-verify 2>&1 | tee "$output_file"
exit_code="${PIPESTATUS[0]}"
if [ "${exit_code}" -eq 0 ]; then
echo "status=success" >> "$GITHUB_OUTPUT"
@@ -156,7 +115,7 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: Mark workflow as failed if cherry-pick failed
if: steps.run_cherry_pick.outputs.status == 'failure'
if: steps.gate.outputs.should_cherrypick == 'true' && steps.run_cherry_pick.outputs.status == 'failure'
env:
CHERRY_PICK_REASON: ${{ steps.run_cherry_pick.outputs.reason }}
run: |
@@ -165,9 +124,8 @@ jobs:
notify-slack-on-cherry-pick-failure:
needs:
- resolve-cherry-pick-request
- cherry-pick-to-latest-release
if: always() && needs.resolve-cherry-pick-request.outputs.should_cherrypick == 'true' && (needs.resolve-cherry-pick-request.result == 'failure' || needs.cherry-pick-to-latest-release.result == 'failure')
if: always() && needs.cherry-pick-to-latest-release.outputs.should_cherrypick == 'true' && needs.cherry-pick-to-latest-release.result != 'success'
runs-on: ubuntu-slim
timeout-minutes: 10
steps:
@@ -176,49 +134,22 @@ jobs:
with:
persist-credentials: false
- name: Fail if Slack webhook secret is missing
env:
CHERRY_PICK_PRS_WEBHOOK: ${{ secrets.CHERRY_PICK_PRS_WEBHOOK }}
run: |
if [ -z "${CHERRY_PICK_PRS_WEBHOOK}" ]; then
echo "::error::CHERRY_PICK_PRS_WEBHOOK is not configured."
exit 1
fi
- name: Build cherry-pick failure summary
id: failure-summary
env:
SOURCE_PR_NUMBER: ${{ needs.resolve-cherry-pick-request.outputs.pr_number }}
MERGE_COMMIT_SHA: ${{ needs.resolve-cherry-pick-request.outputs.merge_commit_sha }}
GATE_ERROR: ${{ needs.resolve-cherry-pick-request.outputs.gate_error }}
SOURCE_PR_NUMBER: ${{ needs.cherry-pick-to-latest-release.outputs.pr_number }}
CHERRY_PICK_REASON: ${{ needs.cherry-pick-to-latest-release.outputs.cherry_pick_reason }}
CHERRY_PICK_DETAILS: ${{ needs.cherry-pick-to-latest-release.outputs.cherry_pick_details }}
run: |
source_pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${SOURCE_PR_NUMBER}"
reason_text="cherry-pick command failed"
if [ "${GATE_ERROR}" = "missing-merge-commit-sha" ]; then
reason_text="requested cherry-pick but merge commit SHA was missing"
elif [ "${GATE_ERROR}" = "team-api-error" ]; then
reason_text="team membership lookup failed while validating cherry-pick permissions"
elif [ "${GATE_ERROR}" = "not-team-member" ]; then
reason_text="merger is not an active member of the allowed team"
elif [ "${CHERRY_PICK_REASON}" = "output-capture-failed" ]; then
reason_text="failed to capture cherry-pick output for classification"
elif [ "${CHERRY_PICK_REASON}" = "merge-conflict" ]; then
if [ "${CHERRY_PICK_REASON}" = "merge-conflict" ]; then
reason_text="merge conflict during cherry-pick"
fi
details_excerpt="$(printf '%s' "${CHERRY_PICK_DETAILS}" | tail -n 8 | tr '\n' ' ' | sed "s/[[:space:]]\\+/ /g" | sed "s/\"/'/g" | cut -c1-350)"
if [ -n "${GATE_ERROR}" ]; then
failed_job_label="resolve-cherry-pick-request"
else
failed_job_label="cherry-pick-to-latest-release"
fi
failed_jobs="• ${failed_job_label}\\n• source PR: ${source_pr_url}\\n• reason: ${reason_text}"
if [ -n "${MERGE_COMMIT_SHA}" ]; then
failed_jobs="${failed_jobs}\\n• merge SHA: ${MERGE_COMMIT_SHA}"
fi
failed_jobs="• cherry-pick-to-latest-release\\n• source PR: ${source_pr_url}\\n• reason: ${reason_text}"
if [ -n "${details_excerpt}" ]; then
failed_jobs="${failed_jobs}\\n• excerpt: ${details_excerpt}"
fi
@@ -231,4 +162,4 @@ jobs:
webhook-url: ${{ secrets.CHERRY_PICK_PRS_WEBHOOK }}
failed-jobs: ${{ steps.failure-summary.outputs.jobs }}
title: "🚨 Automated Cherry-Pick Failed"
ref-name: ${{ github.event.pull_request.base.ref }}
ref-name: ${{ github.ref_name }}

View File

@@ -133,7 +133,7 @@ jobs:
echo "=== Validating chart dependencies ==="
cd deployment/helm/charts/onyx
helm dependency update
helm lint . --set auth.userauth.values.user_auth_secret=placeholder
helm lint .
- name: Run chart-testing (install) with enhanced monitoring
timeout-minutes: 25
@@ -194,7 +194,6 @@ jobs:
--set=vespa.enabled=false \
--set=opensearch.enabled=true \
--set=auth.opensearch.enabled=true \
--set=auth.userauth.values.user_auth_secret=test-secret \
--set=slackbot.enabled=false \
--set=postgresql.enabled=true \
--set=postgresql.cluster.storage.storageClass=standard \
@@ -231,10 +230,6 @@ jobs:
if: steps.list-changed.outputs.changed == 'true'
run: |
echo "=== Post-install verification ==="
if ! kubectl cluster-info >/dev/null 2>&1; then
echo "ERROR: Kubernetes cluster is not reachable after install"
exit 1
fi
kubectl get pods --all-namespaces
kubectl get services --all-namespaces
# Only show issues if they exist
@@ -244,10 +239,6 @@ jobs:
if: failure() && steps.list-changed.outputs.changed == 'true'
run: |
echo "=== Cleanup on failure ==="
if ! kubectl cluster-info >/dev/null 2>&1; then
echo "Skipping failure cleanup: Kubernetes cluster is not reachable"
exit 0
fi
echo "=== Final cluster state ==="
kubectl get pods --all-namespaces
kubectl get events --all-namespaces --sort-by=.lastTimestamp | tail -10

View File

@@ -0,0 +1,42 @@
"""add multi-model columns to chat_message
Revision ID: a3f8b2c1d4e5
Revises: 27fb147a843f
Create Date: 2026-03-12 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "a3f8b2c1d4e5"
down_revision = "27fb147a843f"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"chat_message",
sa.Column(
"preferred_response_id",
sa.Integer(),
sa.ForeignKey("chat_message.id"),
nullable=True,
),
)
op.add_column(
"chat_message",
sa.Column(
"model_display_name",
sa.String(),
nullable=True,
),
)
def downgrade() -> None:
op.drop_column("chat_message", "model_display_name")
op.drop_column("chat_message", "preferred_response_id")

View File

@@ -1,5 +1,3 @@
import base64
import hashlib
import json
import os
import random
@@ -31,7 +29,6 @@ from fastapi import Query
from fastapi import Request
from fastapi import Response
from fastapi import status
from fastapi.responses import JSONResponse
from fastapi.responses import RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm
from fastapi_users import BaseUserManager
@@ -58,7 +55,6 @@ from fastapi_users.router.common import ErrorModel
from fastapi_users_db_sqlalchemy import SQLAlchemyUserDatabase
from httpx_oauth.integrations.fastapi import OAuth2AuthorizeCallback
from httpx_oauth.oauth2 import BaseOAuth2
from httpx_oauth.oauth2 import GetAccessTokenError
from httpx_oauth.oauth2 import OAuth2Token
from pydantic import BaseModel
from sqlalchemy import nulls_last
@@ -124,10 +120,6 @@ from onyx.db.models import Persona
from onyx.db.models import User
from onyx.db.pat import fetch_user_for_pat
from onyx.db.users import get_user_by_email
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import log_onyx_error
from onyx.error_handling.exceptions import onyx_error_to_json_response
from onyx.error_handling.exceptions import OnyxError
from onyx.redis.redis_pool import get_async_redis_connection
from onyx.server.settings.store import load_settings
from onyx.server.utils import BasicAuthenticationError
@@ -1629,7 +1621,6 @@ STATE_TOKEN_AUDIENCE = "fastapi-users:oauth-state"
STATE_TOKEN_LIFETIME_SECONDS = 3600
CSRF_TOKEN_KEY = "csrftoken"
CSRF_TOKEN_COOKIE_NAME = "fastapiusersoauthcsrf"
PKCE_COOKIE_NAME_PREFIX = "fastapiusersoauthpkce"
class OAuth2AuthorizeResponse(BaseModel):
@@ -1650,21 +1641,6 @@ def generate_csrf_token() -> str:
return secrets.token_urlsafe(32)
def _base64url_encode(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def generate_pkce_pair() -> tuple[str, str]:
verifier = secrets.token_urlsafe(64)
challenge = _base64url_encode(hashlib.sha256(verifier.encode("ascii")).digest())
return verifier, challenge
def get_pkce_cookie_name(state: str) -> str:
state_hash = hashlib.sha256(state.encode("utf-8")).hexdigest()
return f"{PKCE_COOKIE_NAME_PREFIX}_{state_hash}"
# refer to https://github.com/fastapi-users/fastapi-users/blob/42ddc241b965475390e2bce887b084152ae1a2cd/fastapi_users/fastapi_users.py#L91
def create_onyx_oauth_router(
oauth_client: BaseOAuth2,
@@ -1673,7 +1649,6 @@ def create_onyx_oauth_router(
redirect_url: Optional[str] = None,
associate_by_email: bool = False,
is_verified_by_default: bool = False,
enable_pkce: bool = False,
) -> APIRouter:
return get_oauth_router(
oauth_client,
@@ -1683,7 +1658,6 @@ def create_onyx_oauth_router(
redirect_url,
associate_by_email,
is_verified_by_default,
enable_pkce=enable_pkce,
)
@@ -1702,7 +1676,6 @@ def get_oauth_router(
csrf_token_cookie_secure: Optional[bool] = None,
csrf_token_cookie_httponly: bool = True,
csrf_token_cookie_samesite: Optional[Literal["lax", "strict", "none"]] = "lax",
enable_pkce: bool = False,
) -> APIRouter:
"""Generate a router with the OAuth routes."""
router = APIRouter()
@@ -1719,13 +1692,6 @@ def get_oauth_router(
route_name=callback_route_name,
)
async def null_access_token_state() -> tuple[OAuth2Token, Optional[str]] | None:
return None
access_token_state_dependency = (
oauth2_authorize_callback if not enable_pkce else null_access_token_state
)
if csrf_token_cookie_secure is None:
csrf_token_cookie_secure = WEB_DOMAIN.startswith("https")
@@ -1759,26 +1725,13 @@ def get_oauth_router(
CSRF_TOKEN_KEY: csrf_token,
}
state = generate_state_token(state_data, state_secret)
pkce_cookie: tuple[str, str] | None = None
if enable_pkce:
code_verifier, code_challenge = generate_pkce_pair()
pkce_cookie_name = get_pkce_cookie_name(state)
pkce_cookie = (pkce_cookie_name, code_verifier)
authorization_url = await oauth_client.get_authorization_url(
authorize_redirect_url,
state,
scopes,
code_challenge=code_challenge,
code_challenge_method="S256",
)
else:
# Get the basic authorization URL
authorization_url = await oauth_client.get_authorization_url(
authorize_redirect_url,
state,
scopes,
)
# Get the basic authorization URL
authorization_url = await oauth_client.get_authorization_url(
authorize_redirect_url,
state,
scopes,
)
# For Google OAuth, add parameters to request refresh tokens
if oauth_client.name == "google":
@@ -1786,15 +1739,11 @@ def get_oauth_router(
authorization_url, {"access_type": "offline", "prompt": "consent"}
)
def set_oauth_cookie(
target_response: Response,
*,
key: str,
value: str,
) -> None:
target_response.set_cookie(
key=key,
value=value,
if redirect:
redirect_response = RedirectResponse(authorization_url, status_code=302)
redirect_response.set_cookie(
key=csrf_token_cookie_name,
value=csrf_token,
max_age=STATE_TOKEN_LIFETIME_SECONDS,
path=csrf_token_cookie_path,
domain=csrf_token_cookie_domain,
@@ -1802,28 +1751,18 @@ def get_oauth_router(
httponly=csrf_token_cookie_httponly,
samesite=csrf_token_cookie_samesite,
)
return redirect_response
response_with_cookies: Response
if redirect:
response_with_cookies = RedirectResponse(authorization_url, status_code=302)
else:
response_with_cookies = response
set_oauth_cookie(
response_with_cookies,
response.set_cookie(
key=csrf_token_cookie_name,
value=csrf_token,
max_age=STATE_TOKEN_LIFETIME_SECONDS,
path=csrf_token_cookie_path,
domain=csrf_token_cookie_domain,
secure=csrf_token_cookie_secure,
httponly=csrf_token_cookie_httponly,
samesite=csrf_token_cookie_samesite,
)
if pkce_cookie is not None:
pkce_cookie_name, code_verifier = pkce_cookie
set_oauth_cookie(
response_with_cookies,
key=pkce_cookie_name,
value=code_verifier,
)
if redirect:
return response_with_cookies
return OAuth2AuthorizeResponse(authorization_url=authorization_url)
@@ -1854,242 +1793,119 @@ def get_oauth_router(
)
async def callback(
request: Request,
access_token_state: Tuple[OAuth2Token, Optional[str]] | None = Depends(
access_token_state_dependency
access_token_state: Tuple[OAuth2Token, str] = Depends(
oauth2_authorize_callback
),
code: Optional[str] = None,
state: Optional[str] = None,
error: Optional[str] = None,
user_manager: BaseUserManager[models.UP, models.ID] = Depends(get_user_manager),
strategy: Strategy[models.UP, models.ID] = Depends(backend.get_strategy),
) -> Response:
pkce_cookie_name: str | None = None
) -> RedirectResponse:
token, state = access_token_state
account_id, account_email = await oauth_client.get_id_email(
token["access_token"]
)
def delete_pkce_cookie(response: Response) -> None:
if enable_pkce and pkce_cookie_name:
response.delete_cookie(
key=pkce_cookie_name,
path=csrf_token_cookie_path,
domain=csrf_token_cookie_domain,
secure=csrf_token_cookie_secure,
httponly=csrf_token_cookie_httponly,
samesite=csrf_token_cookie_samesite,
)
def build_error_response(exc: OnyxError) -> JSONResponse:
log_onyx_error(exc)
error_response = onyx_error_to_json_response(exc)
delete_pkce_cookie(error_response)
return error_response
def decode_and_validate_state(state_value: str) -> Dict[str, str]:
try:
state_data = decode_jwt(
state_value, state_secret, [STATE_TOKEN_AUDIENCE]
)
except jwt.DecodeError:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
getattr(
ErrorCode,
"ACCESS_TOKEN_DECODE_ERROR",
"ACCESS_TOKEN_DECODE_ERROR",
),
)
except jwt.ExpiredSignatureError:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
getattr(
ErrorCode,
"ACCESS_TOKEN_ALREADY_EXPIRED",
"ACCESS_TOKEN_ALREADY_EXPIRED",
),
)
except jwt.PyJWTError:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
getattr(
ErrorCode,
"ACCESS_TOKEN_DECODE_ERROR",
"ACCESS_TOKEN_DECODE_ERROR",
),
)
cookie_csrf_token = request.cookies.get(csrf_token_cookie_name)
state_csrf_token = state_data.get(CSRF_TOKEN_KEY)
if (
not cookie_csrf_token
or not state_csrf_token
or not secrets.compare_digest(cookie_csrf_token, state_csrf_token)
):
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
getattr(ErrorCode, "OAUTH_INVALID_STATE", "OAUTH_INVALID_STATE"),
)
return state_data
token: OAuth2Token
state_data: Dict[str, str]
# `code`, `state`, and `error` are read directly only in the PKCE path.
# In the non-PKCE path, `oauth2_authorize_callback` consumes them.
if enable_pkce:
if state is not None:
pkce_cookie_name = get_pkce_cookie_name(state)
if error is not None:
return build_error_response(
OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Authorization request failed or was denied",
)
)
if code is None:
return build_error_response(
OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Missing authorization code in OAuth callback",
)
)
if state is None:
return build_error_response(
OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Missing state parameter in OAuth callback",
)
)
state_value = state
if redirect_url is not None:
callback_redirect_url = redirect_url
else:
callback_path = request.app.url_path_for(callback_route_name)
callback_redirect_url = f"{WEB_DOMAIN}{callback_path}"
code_verifier = request.cookies.get(cast(str, pkce_cookie_name))
if not code_verifier:
return build_error_response(
OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Missing PKCE verifier cookie in OAuth callback",
)
)
try:
state_data = decode_and_validate_state(state_value)
except OnyxError as e:
return build_error_response(e)
try:
token = await oauth_client.get_access_token(
code, callback_redirect_url, code_verifier
)
except GetAccessTokenError:
return build_error_response(
OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Authorization code exchange failed",
)
)
else:
if access_token_state is None:
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR, "Missing OAuth callback state"
)
token, callback_state = access_token_state
if callback_state is None:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"Missing state parameter in OAuth callback",
)
state_data = decode_and_validate_state(callback_state)
async def complete_login_flow(
token: OAuth2Token, state_data: Dict[str, str]
) -> RedirectResponse:
account_id, account_email = await oauth_client.get_id_email(
token["access_token"]
if account_email is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.OAUTH_NOT_AVAILABLE_EMAIL,
)
if account_email is None:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
ErrorCode.OAUTH_NOT_AVAILABLE_EMAIL,
)
try:
state_data = decode_jwt(state, state_secret, [STATE_TOKEN_AUDIENCE])
except jwt.DecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=getattr(
ErrorCode, "ACCESS_TOKEN_DECODE_ERROR", "ACCESS_TOKEN_DECODE_ERROR"
),
)
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=getattr(
ErrorCode,
"ACCESS_TOKEN_ALREADY_EXPIRED",
"ACCESS_TOKEN_ALREADY_EXPIRED",
),
)
next_url = state_data.get("next_url", "/")
referral_source = state_data.get("referral_source", None)
try:
tenant_id = fetch_ee_implementation_or_noop(
"onyx.server.tenants.user_mapping", "get_tenant_id_for_email", None
)(account_email)
except exceptions.UserNotExists:
tenant_id = None
cookie_csrf_token = request.cookies.get(csrf_token_cookie_name)
state_csrf_token = state_data.get(CSRF_TOKEN_KEY)
if (
not cookie_csrf_token
or not state_csrf_token
or not secrets.compare_digest(cookie_csrf_token, state_csrf_token)
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=getattr(ErrorCode, "OAUTH_INVALID_STATE", "OAUTH_INVALID_STATE"),
)
request.state.referral_source = referral_source
next_url = state_data.get("next_url", "/")
referral_source = state_data.get("referral_source", None)
try:
tenant_id = fetch_ee_implementation_or_noop(
"onyx.server.tenants.user_mapping", "get_tenant_id_for_email", None
)(account_email)
except exceptions.UserNotExists:
tenant_id = None
# Proceed to authenticate or create the user
try:
user = await user_manager.oauth_callback(
oauth_client.name,
token["access_token"],
account_id,
account_email,
token.get("expires_at"),
token.get("refresh_token"),
request,
associate_by_email=associate_by_email,
is_verified_by_default=is_verified_by_default,
)
except UserAlreadyExists:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
ErrorCode.OAUTH_USER_ALREADY_EXISTS,
)
request.state.referral_source = referral_source
if not user.is_active:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
ErrorCode.LOGIN_BAD_CREDENTIALS,
)
# Proceed to authenticate or create the user
try:
user = await user_manager.oauth_callback(
oauth_client.name,
token["access_token"],
account_id,
account_email,
token.get("expires_at"),
token.get("refresh_token"),
request,
associate_by_email=associate_by_email,
is_verified_by_default=is_verified_by_default,
)
except UserAlreadyExists:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.OAUTH_USER_ALREADY_EXISTS,
)
# Login user
response = await backend.login(strategy, user)
await user_manager.on_after_login(user, request, response)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorCode.LOGIN_BAD_CREDENTIALS,
)
# Prepare redirect response
if tenant_id is None:
# Use URL utility to add parameters
redirect_destination = add_url_params(next_url, {"new_team": "true"})
redirect_response = RedirectResponse(
redirect_destination, status_code=302
)
# Login user
response = await backend.login(strategy, user)
await user_manager.on_after_login(user, request, response)
# Prepare redirect response
if tenant_id is None:
# Use URL utility to add parameters
redirect_url = add_url_params(next_url, {"new_team": "true"})
redirect_response = RedirectResponse(redirect_url, status_code=302)
else:
# No parameters to add
redirect_response = RedirectResponse(next_url, status_code=302)
# Copy headers from auth response to redirect response, with special handling for Set-Cookie
for header_name, header_value in response.headers.items():
# FastAPI can have multiple Set-Cookie headers as a list
if header_name.lower() == "set-cookie" and isinstance(header_value, list):
for cookie_value in header_value:
redirect_response.headers.append(header_name, cookie_value)
else:
# No parameters to add
redirect_response = RedirectResponse(next_url, status_code=302)
# Copy headers from auth response to redirect response, with special handling for Set-Cookie
for header_name, header_value in response.headers.items():
header_name_lower = header_name.lower()
if header_name_lower == "set-cookie":
redirect_response.headers.append(header_name, header_value)
continue
if header_name_lower in {"location", "content-length"}:
continue
redirect_response.headers[header_name] = header_value
return redirect_response
if hasattr(response, "body"):
redirect_response.body = response.body
if hasattr(response, "status_code"):
redirect_response.status_code = response.status_code
if hasattr(response, "media_type"):
redirect_response.media_type = response.media_type
if enable_pkce:
try:
redirect_response = await complete_login_flow(token, state_data)
except OnyxError as e:
return build_error_response(e)
delete_pkce_cookie(redirect_response)
return redirect_response
return await complete_login_flow(token, state_data)
return redirect_response
return router

View File

@@ -8,6 +8,7 @@ from onyx.configs.constants import MessageType
from onyx.context.search.models import SearchDoc
from onyx.file_store.models import InMemoryChatFile
from onyx.server.query_and_chat.models import MessageResponseIDInfo
from onyx.server.query_and_chat.models import MultiModelMessageResponseIDInfo
from onyx.server.query_and_chat.streaming_models import CitationInfo
from onyx.server.query_and_chat.streaming_models import GeneratedImage
from onyx.server.query_and_chat.streaming_models import Packet
@@ -35,7 +36,13 @@ class CreateChatSessionID(BaseModel):
chat_session_id: UUID
AnswerStreamPart = Packet | MessageResponseIDInfo | StreamingError | CreateChatSessionID
AnswerStreamPart = (
Packet
| MessageResponseIDInfo
| MultiModelMessageResponseIDInfo
| StreamingError
| CreateChatSessionID
)
AnswerStream = Iterator[AnswerStreamPart]

View File

@@ -196,10 +196,6 @@ if _OIDC_SCOPE_OVERRIDE:
except Exception:
pass
# Enables PKCE for OIDC login flow. Disabled by default to preserve
# backwards compatibility for existing OIDC deployments.
OIDC_PKCE_ENABLED = os.environ.get("OIDC_PKCE_ENABLED", "").lower() == "true"
# Applicable for SAML Auth
SAML_CONF_DIR = os.environ.get("SAML_CONF_DIR") or "/app/onyx/configs/saml_config"

View File

@@ -33,7 +33,6 @@ from office365.runtime.queries.client_query import ClientQuery # type: ignore[i
from office365.sharepoint.client_context import ClientContext # type: ignore[import-untyped]
from pydantic import BaseModel
from pydantic import Field
from requests.exceptions import HTTPError
from onyx.configs.app_configs import INDEX_BATCH_SIZE
from onyx.configs.app_configs import REQUEST_TIMEOUT_SECONDS
@@ -273,15 +272,6 @@ class SizeCapExceeded(Exception):
"""Exception raised when the size cap is exceeded."""
def _log_and_raise_for_status(response: requests.Response) -> None:
"""Log the response text and raise for status."""
try:
response.raise_for_status()
except Exception:
logger.error(f"HTTP request failed: {response.text}")
raise
def load_certificate_from_pfx(pfx_data: bytes, password: str) -> CertificateData | None:
"""Load certificate from .pfx file for MSAL authentication"""
try:
@@ -358,7 +348,7 @@ def _probe_remote_size(url: str, timeout: int) -> int | None:
"""Determine remote size using HEAD or a range GET probe. Returns None if unknown."""
try:
head_resp = requests.head(url, timeout=timeout, allow_redirects=True)
_log_and_raise_for_status(head_resp)
head_resp.raise_for_status()
cl = head_resp.headers.get("Content-Length")
if cl and cl.isdigit():
return int(cl)
@@ -373,7 +363,7 @@ def _probe_remote_size(url: str, timeout: int) -> int | None:
timeout=timeout,
stream=True,
) as range_resp:
_log_and_raise_for_status(range_resp)
range_resp.raise_for_status()
cr = range_resp.headers.get("Content-Range") # e.g., "bytes 0-0/12345"
if cr and "/" in cr:
total = cr.split("/")[-1]
@@ -398,7 +388,7 @@ def _download_with_cap(url: str, timeout: int, cap: int) -> bytes:
- Returns the full bytes if the content fits within `cap`.
"""
with requests.get(url, stream=True, timeout=timeout) as resp:
_log_and_raise_for_status(resp)
resp.raise_for_status()
# If the server provides Content-Length, prefer an early decision.
cl_header = resp.headers.get("Content-Length")
@@ -442,7 +432,7 @@ def _download_via_graph_api(
with requests.get(
url, headers=headers, stream=True, timeout=REQUEST_TIMEOUT_SECONDS
) as resp:
_log_and_raise_for_status(resp)
resp.raise_for_status()
buf = io.BytesIO()
for chunk in resp.iter_content(64 * 1024):
if not chunk:
@@ -1259,14 +1249,7 @@ class SharepointConnector(
total_yielded = 0
while page_url:
try:
data = self._graph_api_get_json(page_url, params)
except HTTPError as e:
if e.response.status_code == 404:
logger.warning(f"Site page not found: {page_url}")
break
raise
data = self._graph_api_get_json(page_url, params)
params = None # nextLink already embeds query params
for page in data.get("value", []):
@@ -1330,7 +1313,7 @@ class SharepointConnector(
access_token = self._get_graph_access_token()
headers = {"Authorization": f"Bearer {access_token}"}
continue
_log_and_raise_for_status(response)
response.raise_for_status()
return response.json()
except (requests.ConnectionError, requests.Timeout):
if attempt < GRAPH_API_MAX_RETRIES:

View File

@@ -2622,6 +2622,18 @@ class ChatMessage(Base):
ForeignKey("chat_message.id"), nullable=True
)
# For multi-model turns: the user message points to which assistant response
# was selected as the preferred one to continue the conversation with.
# Only set on user messages that triggered a multi-model generation.
preferred_response_id: Mapped[int | None] = mapped_column(
ForeignKey("chat_message.id"), nullable=True
)
# The display name of the model that generated this assistant message
# (e.g. "GPT-4", "Claude Opus"). Used on session reload to label
# multi-model response panels and <> navigation arrows.
model_display_name: Mapped[str | None] = mapped_column(String, nullable=True)
# Only set on summary messages - the ID of the last message included in this summary
# Used for chat history compression
last_summarized_message_id: Mapped[int | None] = mapped_column(
@@ -2696,6 +2708,12 @@ class ChatMessage(Base):
remote_side="ChatMessage.id",
)
preferred_response: Mapped["ChatMessage | None"] = relationship(
"ChatMessage",
foreign_keys=[preferred_response_id],
remote_side="ChatMessage.id",
)
# Chat messages only need to know their immediate tool call children
# If there are nested tool calls, they are stored in the tool_call_children relationship.
tool_calls: Mapped[list["ToolCall"] | None] = relationship(

View File

@@ -59,22 +59,6 @@ class OnyxError(Exception):
return self._status_code_override or self.error_code.status_code
def log_onyx_error(exc: OnyxError) -> None:
detail = exc.detail
status_code = exc.status_code
if status_code >= 500:
logger.error(f"OnyxError {exc.error_code.code}: {detail}")
elif status_code >= 400:
logger.warning(f"OnyxError {exc.error_code.code}: {detail}")
def onyx_error_to_json_response(exc: OnyxError) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content=exc.error_code.detail(exc.detail),
)
def register_onyx_exception_handlers(app: FastAPI) -> None:
"""Register a global handler that converts ``OnyxError`` to JSON responses.
@@ -87,5 +71,13 @@ def register_onyx_exception_handlers(app: FastAPI) -> None:
request: Request, # noqa: ARG001
exc: OnyxError,
) -> JSONResponse:
log_onyx_error(exc)
return onyx_error_to_json_response(exc)
status_code = exc.status_code
if status_code >= 500:
logger.error(f"OnyxError {exc.error_code.code}: {exc.detail}")
elif status_code >= 400:
logger.warning(f"OnyxError {exc.error_code.code}: {exc.detail}")
return JSONResponse(
status_code=status_code,
content=exc.error_code.detail(exc.detail),
)

View File

@@ -44,7 +44,6 @@ from onyx.configs.app_configs import LOG_ENDPOINT_LATENCY
from onyx.configs.app_configs import OAUTH_CLIENT_ID
from onyx.configs.app_configs import OAUTH_CLIENT_SECRET
from onyx.configs.app_configs import OAUTH_ENABLED
from onyx.configs.app_configs import OIDC_PKCE_ENABLED
from onyx.configs.app_configs import OIDC_SCOPE_OVERRIDE
from onyx.configs.app_configs import OPENID_CONFIG_URL
from onyx.configs.app_configs import POSTGRES_API_SERVER_POOL_OVERFLOW
@@ -598,7 +597,6 @@ def get_application(lifespan_override: Lifespan | None = None) -> FastAPI:
associate_by_email=True,
is_verified_by_default=True,
redirect_url=f"{WEB_DOMAIN}/auth/oidc/callback",
enable_pkce=OIDC_PKCE_ENABLED,
),
prefix="/auth/oidc",
)

View File

@@ -1,4 +1,3 @@
import re
from collections.abc import Iterator
from pathlib import Path
from uuid import UUID
@@ -41,9 +40,6 @@ from onyx.utils.logger import setup_logger
logger = setup_logger()
_TEMPLATES_DIR = Path(__file__).parent / "templates"
_WEBAPP_HMR_FIXER_TEMPLATE = (_TEMPLATES_DIR / "webapp_hmr_fixer.js").read_text()
def require_onyx_craft_enabled(user: User = Depends(current_user)) -> User:
"""
@@ -243,62 +239,18 @@ def _stream_response(response: httpx.Response) -> Iterator[bytes]:
yield chunk
def _inject_hmr_fixer(content: bytes, session_id: str) -> bytes:
"""Inject a script that stubs root-scoped Next HMR websocket connections."""
base = f"/api/build/sessions/{session_id}/webapp"
script = f"<script>{_WEBAPP_HMR_FIXER_TEMPLATE.replace('__WEBAPP_BASE__', base)}</script>"
text = content.decode("utf-8")
text = re.sub(
r"(<head\b[^>]*>)",
lambda m: m.group(0) + script,
text,
count=1,
flags=re.IGNORECASE,
)
return text.encode("utf-8")
def _rewrite_asset_paths(content: bytes, session_id: str) -> bytes:
"""Rewrite Next.js asset paths to go through the proxy."""
import re
# Base path includes session_id for routing
webapp_base_path = f"/api/build/sessions/{session_id}/webapp"
escaped_webapp_base_path = webapp_base_path.replace("/", r"\/")
hmr_paths = ("/_next/webpack-hmr", "/_next/hmr")
text = content.decode("utf-8")
# Anchor on delimiter so already-prefixed URLs (from assetPrefix) aren't double-rewritten.
for delim in ('"', "'", "("):
text = text.replace(f"{delim}/_next/", f"{delim}{webapp_base_path}/_next/")
text = re.sub(
rf"{re.escape(delim)}https?://[^/\"')]+/_next/",
f"{delim}{webapp_base_path}/_next/",
text,
)
text = re.sub(
rf"{re.escape(delim)}wss?://[^/\"')]+/_next/",
f"{delim}{webapp_base_path}/_next/",
text,
)
text = text.replace(r"\/_next\/", rf"{escaped_webapp_base_path}\/_next\/")
text = re.sub(
r"https?:\\\/\\\/[^\"']+?\\\/_next\\\/",
rf"{escaped_webapp_base_path}\/_next\/",
text,
)
text = re.sub(
r"wss?:\\\/\\\/[^\"']+?\\\/_next\\\/",
rf"{escaped_webapp_base_path}\/_next\/",
text,
)
for hmr_path in hmr_paths:
escaped_hmr_path = hmr_path.replace("/", r"\/")
text = text.replace(
f"{webapp_base_path}{hmr_path}",
hmr_path,
)
text = text.replace(
f"{escaped_webapp_base_path}{escaped_hmr_path}",
escaped_hmr_path,
)
# Rewrite /_next/ paths to go through our proxy
text = text.replace("/_next/", f"{webapp_base_path}/_next/")
# Rewrite JSON data file fetch paths (e.g., /data.json, /data/tickets.json)
# Matches paths like "/filename.json" or "/path/to/file.json"
text = re.sub(
r'"(/(?:[a-zA-Z0-9_-]+/)*[a-zA-Z0-9_-]+\.json)"',
f'"{webapp_base_path}\\1"',
@@ -309,29 +261,11 @@ def _rewrite_asset_paths(content: bytes, session_id: str) -> bytes:
f"'{webapp_base_path}\\1'",
text,
)
# Rewrite favicon
text = text.replace('"/favicon.ico', f'"{webapp_base_path}/favicon.ico')
return text.encode("utf-8")
def _rewrite_proxy_response_headers(
headers: dict[str, str], session_id: str
) -> dict[str, str]:
"""Rewrite response headers that can leak root-scoped asset URLs."""
link = headers.get("link")
if link:
webapp_base_path = f"/api/build/sessions/{session_id}/webapp"
rewritten_link = re.sub(
r"<https?://[^>]+/_next/",
f"<{webapp_base_path}/_next/",
link,
)
rewritten_link = rewritten_link.replace(
"</_next/", f"<{webapp_base_path}/_next/"
)
headers["link"] = rewritten_link
return headers
# Content types that may contain asset path references that need rewriting
REWRITABLE_CONTENT_TYPES = {
"text/html",
@@ -408,17 +342,12 @@ def _proxy_request(
for key, value in response.headers.items()
if key.lower() not in EXCLUDED_HEADERS
}
response_headers = _rewrite_proxy_response_headers(
response_headers, str(session_id)
)
content_type = response.headers.get("content-type", "")
# For HTML/CSS/JS responses, rewrite asset paths
if any(ct in content_type for ct in REWRITABLE_CONTENT_TYPES):
content = _rewrite_asset_paths(response.content, str(session_id))
if "text/html" in content_type:
content = _inject_hmr_fixer(content, str(session_id))
return Response(
content=content,
status_code=response.status_code,
@@ -462,7 +391,7 @@ def _check_webapp_access(
return session
_OFFLINE_HTML_PATH = _TEMPLATES_DIR / "webapp_offline.html"
_OFFLINE_HTML_PATH = Path(__file__).parent / "templates" / "webapp_offline.html"
def _offline_html_response() -> Response:
@@ -470,7 +399,6 @@ def _offline_html_response() -> Response:
Design mirrors the default Craft web template (outputs/web/app/page.tsx):
terminal window aesthetic with Minecraft-themed typing animation.
"""
html = _OFFLINE_HTML_PATH.read_text()
return Response(content=html, status_code=503, media_type="text/html")

View File

@@ -1,135 +0,0 @@
(function () {
var WEBAPP_BASE = "__WEBAPP_BASE__";
var PROXIED_NEXT_PREFIX = WEBAPP_BASE + "/_next/";
var PROXIED_HMR_PREFIX = WEBAPP_BASE + "/_next/webpack-hmr";
var PROXIED_ALT_HMR_PREFIX = WEBAPP_BASE + "/_next/hmr";
function isHmrWebSocketUrl(url) {
if (!url) return false;
try {
var parsedUrl = new URL(String(url), window.location.href);
return (
parsedUrl.pathname.indexOf("/_next/webpack-hmr") === 0 ||
parsedUrl.pathname.indexOf("/_next/hmr") === 0 ||
parsedUrl.pathname.indexOf(PROXIED_HMR_PREFIX) === 0 ||
parsedUrl.pathname.indexOf(PROXIED_ALT_HMR_PREFIX) === 0
);
} catch (e) {}
if (typeof url === "string") {
return (
url.indexOf("/_next/webpack-hmr") === 0 ||
url.indexOf("/_next/hmr") === 0 ||
url.indexOf(PROXIED_HMR_PREFIX) === 0 ||
url.indexOf(PROXIED_ALT_HMR_PREFIX) === 0
);
}
return false;
}
function rewriteNextAssetUrl(url) {
if (!url) return url;
try {
var parsedUrl = new URL(String(url), window.location.href);
if (parsedUrl.pathname.indexOf(PROXIED_NEXT_PREFIX) === 0) {
return parsedUrl.pathname + parsedUrl.search + parsedUrl.hash;
}
if (parsedUrl.pathname.indexOf("/_next/") === 0) {
return (
WEBAPP_BASE + parsedUrl.pathname + parsedUrl.search + parsedUrl.hash
);
}
} catch (e) {}
if (typeof url === "string") {
if (url.indexOf(PROXIED_NEXT_PREFIX) === 0) {
return url;
}
if (url.indexOf("/_next/") === 0) {
return WEBAPP_BASE + url;
}
}
return url;
}
function createEvent(eventType) {
return typeof Event === "function"
? new Event(eventType)
: { type: eventType };
}
function MockHmrWebSocket(url) {
this.url = String(url);
this.readyState = 1;
this.bufferedAmount = 0;
this.extensions = "";
this.protocol = "";
this.binaryType = "blob";
this.onopen = null;
this.onmessage = null;
this.onerror = null;
this.onclose = null;
this._l = {};
var socket = this;
setTimeout(function () {
socket._d("open", createEvent("open"));
}, 0);
}
MockHmrWebSocket.CONNECTING = 0;
MockHmrWebSocket.OPEN = 1;
MockHmrWebSocket.CLOSING = 2;
MockHmrWebSocket.CLOSED = 3;
MockHmrWebSocket.prototype.addEventListener = function (eventType, callback) {
(this._l[eventType] || (this._l[eventType] = [])).push(callback);
};
MockHmrWebSocket.prototype.removeEventListener = function (
eventType,
callback,
) {
var listeners = this._l[eventType] || [];
this._l[eventType] = listeners.filter(function (listener) {
return listener !== callback;
});
};
MockHmrWebSocket.prototype._d = function (eventType, eventValue) {
var listeners = this._l[eventType] || [];
for (var i = 0; i < listeners.length; i++) {
listeners[i].call(this, eventValue);
}
var handler = this["on" + eventType];
if (typeof handler === "function") {
handler.call(this, eventValue);
}
};
MockHmrWebSocket.prototype.send = function () {};
MockHmrWebSocket.prototype.close = function (code, reason) {
if (this.readyState >= 2) return;
this.readyState = 3;
var closeEvent = createEvent("close");
closeEvent.code = code === undefined ? 1000 : code;
closeEvent.reason = reason || "";
closeEvent.wasClean = true;
this._d("close", closeEvent);
};
if (window.WebSocket) {
var OriginalWebSocket = window.WebSocket;
window.WebSocket = function (url, protocols) {
if (isHmrWebSocketUrl(url)) {
return new MockHmrWebSocket(rewriteNextAssetUrl(url));
}
return protocols === undefined
? new OriginalWebSocket(url)
: new OriginalWebSocket(url, protocols);
};
window.WebSocket.prototype = OriginalWebSocket.prototype;
Object.setPrototypeOf(window.WebSocket, OriginalWebSocket);
["CONNECTING", "OPEN", "CLOSING", "CLOSED"].forEach(function (stateKey) {
window.WebSocket[stateKey] = OriginalWebSocket[stateKey];
});
}
})();

View File

@@ -157,13 +157,10 @@ def categorize_uploaded_files(
"""
Categorize uploaded files based on text extractability and tokenized length.
- Images are estimated for token cost via a patch-based heuristic.
- All other files are run through extract_file_text, which handles known
document formats (.pdf, .docx, …) and falls back to a text-detection
heuristic for unknown extensions (.py, .js, .rs, …).
- Extracts text using extract_file_text for supported plain/document extensions.
- Uses default tokenizer to compute token length.
- If token length > threshold, reject file (unless threshold skip is enabled).
- If text cannot be extracted, reject file.
- If token length > 100,000, reject file (unless threshold skip is enabled).
- If extension unsupported or text cannot be extracted, reject file.
- Otherwise marked as acceptable.
"""
@@ -220,7 +217,8 @@ def categorize_uploaded_files(
)
results.rejected.append(
RejectedFile(
filename=filename, reason="Unsupported file contents"
filename=filename,
reason=f"Unsupported file type: {extension}",
)
)
continue
@@ -237,10 +235,8 @@ def categorize_uploaded_files(
results.acceptable_file_to_token_count[filename] = token_count
continue
# Handle as text/document: attempt text extraction and count tokens.
# This accepts any file that extract_file_text can handle, including
# code files (.py, .js, .rs, etc.) via its is_text_file() fallback.
else:
# Otherwise, handle as text/document: extract text and count tokens
elif extension in OnyxFileExtensions.ALL_ALLOWED_EXTENSIONS:
if is_file_password_protected(
file=upload.file,
file_name=filename,
@@ -263,10 +259,7 @@ def categorize_uploaded_files(
if not text_content:
logger.warning(f"No text content extracted from '{filename}'")
results.rejected.append(
RejectedFile(
filename=filename,
reason=f"Unsupported file type: {extension}",
)
RejectedFile(filename=filename, reason="Could not read file")
)
continue
@@ -289,6 +282,17 @@ def categorize_uploaded_files(
logger.warning(
f"Failed to reset file pointer for '{filename}': {str(e)}"
)
continue
# If not recognized as supported types above, mark unsupported
logger.warning(
f"Unsupported file extension '{extension}' for file '{filename}'"
)
results.rejected.append(
RejectedFile(
filename=filename, reason=f"Unsupported file type: {extension}"
)
)
except Exception as e:
logger.warning(
f"Failed to process uploaded file '{get_safe_filename(upload)}' (error_type={type(e).__name__}, error={str(e)})"

View File

@@ -41,6 +41,16 @@ class MessageResponseIDInfo(BaseModel):
reserved_assistant_message_id: int
class MultiModelMessageResponseIDInfo(BaseModel):
"""Sent at the start of a multi-model streaming response.
Contains the user message ID and the reserved assistant message IDs
for each model being run in parallel."""
user_message_id: int | None
reserved_assistant_message_ids: list[int]
model_names: list[str]
class SourceTag(Tag):
source: DocumentSource
@@ -86,6 +96,10 @@ class SendMessageRequest(BaseModel):
message: str
llm_override: LLMOverride | None = None
# For multi-model mode: up to 3 LLM overrides to run in parallel.
# When provided with >1 entry, triggers multi-model streaming.
# Backward-compat: if only `llm_override` is set, single-model path is used.
llm_overrides: list[LLMOverride] | None = None
# Test-only override for deterministic LiteLLM mock responses.
mock_llm_response: str | None = None
@@ -211,6 +225,10 @@ class ChatMessageDetail(BaseModel):
error: str | None = None
current_feedback: str | None = None # "like" | "dislike" | null
processing_duration_seconds: float | None = None
# For multi-model turns: the preferred assistant response ID (set on user messages only)
preferred_response_id: int | None = None
# The display name of the model that generated this message (e.g. "GPT-4", "Claude Opus")
model_display_name: str | None = None
def model_dump(self, *args: list, **kwargs: dict[str, Any]) -> dict[str, Any]: # type: ignore
initial_dict = super().model_dump(mode="json", *args, **kwargs) # type: ignore

View File

@@ -8,3 +8,6 @@ class Placement(BaseModel):
tab_index: int = 0
# Used for tools/agents that call other tools, this currently doesn't support nested agents but can be added later
sub_turn_index: int | None = None
# For multi-model streaming: identifies which model (0, 1, 2) this packet belongs to.
# None for single-model (default) responses.
model_index: int | None = None

View File

@@ -614,7 +614,7 @@ opentelemetry-sdk==1.39.1
# opentelemetry-exporter-otlp-proto-http
opentelemetry-semantic-conventions==0.60b1
# via opentelemetry-sdk
orjson==3.11.6 ; platform_python_implementation != 'PyPy'
orjson==3.11.4 ; platform_python_implementation != 'PyPy'
# via langsmith
packaging==24.2
# via

View File

@@ -1,256 +0,0 @@
"""Unit tests for webapp proxy path rewriting/injection."""
from types import SimpleNamespace
from typing import cast
from typing import Literal
from uuid import UUID
import httpx
import pytest
from fastapi import Request
from sqlalchemy.orm import Session
from onyx.server.features.build.api import api
from onyx.server.features.build.api.api import _inject_hmr_fixer
from onyx.server.features.build.api.api import _rewrite_asset_paths
from onyx.server.features.build.api.api import _rewrite_proxy_response_headers
SESSION_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
BASE = f"/api/build/sessions/{SESSION_ID}/webapp"
def rewrite(html: str) -> str:
return _rewrite_asset_paths(html.encode(), SESSION_ID).decode()
def inject(html: str) -> str:
return _inject_hmr_fixer(html.encode(), SESSION_ID).decode()
class TestNextjsPathRewriting:
def test_rewrites_bare_next_script_src(self) -> None:
html = '<script src="/_next/static/chunks/main.js">'
result = rewrite(html)
assert f'src="{BASE}/_next/static/chunks/main.js"' in result
assert '"/_next/' not in result
def test_rewrites_bare_next_in_single_quotes(self) -> None:
html = "<link href='/_next/static/css/app.css'>"
result = rewrite(html)
assert f"'{BASE}/_next/static/css/app.css'" in result
def test_rewrites_bare_next_in_url_parens(self) -> None:
html = "background: url(/_next/static/media/font.woff2)"
result = rewrite(html)
assert f"url({BASE}/_next/static/media/font.woff2)" in result
def test_no_double_prefix_when_already_proxied(self) -> None:
"""assetPrefix makes Next.js emit already-prefixed URLs — must not double-rewrite."""
already_prefixed = f'<script src="{BASE}/_next/static/chunks/main.js">'
result = rewrite(already_prefixed)
# Should be unchanged
assert result == already_prefixed
# Specifically, no double path
assert f"{BASE}/{BASE}" not in result
def test_rewrites_favicon(self) -> None:
html = '<link rel="icon" href="/favicon.ico">'
result = rewrite(html)
assert f'"{BASE}/favicon.ico"' in result
def test_rewrites_json_data_path_double_quoted(self) -> None:
html = 'fetch("/data/tickets.json")'
result = rewrite(html)
assert f'"{BASE}/data/tickets.json"' in result
def test_rewrites_json_data_path_single_quoted(self) -> None:
html = "fetch('/data/items.json')"
result = rewrite(html)
assert f"'{BASE}/data/items.json'" in result
def test_rewrites_escaped_next_font_path_in_json_script(self) -> None:
"""Next dev can embed font asset paths in JSON-escaped script payloads."""
html = r'{"src":"\/_next\/static\/media\/font.woff2"}'
result = rewrite(html)
assert (
r'{"src":"\/api\/build\/sessions\/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\/webapp\/_next\/static\/media\/font.woff2"}'
in result
)
def test_rewrites_escaped_next_font_path_in_style_payload(self) -> None:
"""Keep dynamically generated next/font URLs inside the session proxy."""
html = r'{"css":"@font-face{src:url(\"\/_next\/static\/media\/font.woff2\")"}'
result = rewrite(html)
assert (
r"\/api\/build\/sessions\/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\/webapp\/_next\/static\/media\/font.woff2"
in result
)
def test_rewrites_absolute_next_font_url(self) -> None:
html = (
'<link rel="preload" as="font" '
'href="https://craft-dev.onyx.app/_next/static/media/font.woff2">'
)
result = rewrite(html)
assert f'"{BASE}/_next/static/media/font.woff2"' in result
def test_rewrites_root_hmr_path(self) -> None:
html = 'new WebSocket("wss://craft-dev.onyx.app/_next/webpack-hmr?id=abc")'
result = rewrite(html)
assert '"wss://craft-dev.onyx.app/_next/webpack-hmr?id=abc"' not in result
assert '"/_next/webpack-hmr?id=abc"' in result
def test_rewrites_escaped_absolute_next_font_url(self) -> None:
html = (
r'{"href":"https:\/\/craft-dev.onyx.app\/_next\/static\/media\/font.woff2"}'
)
result = rewrite(html)
assert (
r'{"href":"\/api\/build\/sessions\/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\/webapp\/_next\/static\/media\/font.woff2"}'
in result
)
class TestRuntimeFixerInjection:
def test_injects_websocket_rewrite_shim(self) -> None:
html = "<html><head></head><body></body></html>"
result = inject(html)
assert "window.WebSocket = function (url, protocols)" in result
assert f'var WEBAPP_BASE = "{BASE}"' in result
def test_injects_hmr_websocket_stub(self) -> None:
html = "<html><head></head><body></body></html>"
result = inject(html)
assert "function MockHmrWebSocket(url)" in result
assert "return new MockHmrWebSocket(rewriteNextAssetUrl(url));" in result
def test_injects_before_head_contents(self) -> None:
html = "<html><head><title>x</title></head><body></body></html>"
result = inject(html)
assert result.index(
"window.WebSocket = function (url, protocols)"
) < result.index("<title>x</title>")
def test_rewritten_hmr_url_still_matches_shim_intercept_logic(self) -> None:
html = (
"<html><head></head><body>"
'new WebSocket("wss://craft-dev.onyx.app/_next/webpack-hmr?id=abc")'
"</body></html>"
)
rewritten = rewrite(html)
assert '"wss://craft-dev.onyx.app/_next/webpack-hmr?id=abc"' not in rewritten
assert 'new WebSocket("/_next/webpack-hmr?id=abc")' in rewritten
injected = inject(rewritten)
assert 'new WebSocket("/_next/webpack-hmr?id=abc")' in injected
assert 'parsedUrl.pathname.indexOf("/_next/webpack-hmr") === 0' in injected
class TestProxyHeaderRewriting:
def test_rewrites_link_header_font_preload_paths(self) -> None:
headers = {
"link": (
'</_next/static/media/font.woff2>; rel=preload; as="font"; crossorigin, '
'</_next/static/media/font2.woff2>; rel=preload; as="font"; crossorigin'
)
}
result = _rewrite_proxy_response_headers(headers, SESSION_ID)
assert f"<{BASE}/_next/static/media/font.woff2>" in result["link"]
class TestProxyRequestWiring:
def test_proxy_request_rewrites_link_header_on_html_response(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
html = b"<html><head></head><body>ok</body></html>"
upstream = httpx.Response(
200,
headers={
"content-type": "text/html; charset=utf-8",
"link": '</_next/static/media/font.woff2>; rel=preload; as="font"',
},
content=html,
)
monkeypatch.setattr(api, "_get_sandbox_url", lambda *_args: "http://sandbox")
class FakeClient:
def __init__(self, *_args: object, **_kwargs: object) -> None:
pass
def __enter__(self) -> "FakeClient":
return self
def __exit__(self, *_args: object) -> Literal[False]:
return False
def get(self, _url: str, headers: dict[str, str]) -> httpx.Response:
assert "host" not in {key.lower() for key in headers}
return upstream
monkeypatch.setattr(api.httpx, "Client", FakeClient)
request = cast(Request, SimpleNamespace(headers={}, query_params=""))
response = api._proxy_request(
"", request, UUID(SESSION_ID), cast(Session, SimpleNamespace())
)
assert response.headers["link"] == (
f'<{BASE}/_next/static/media/font.woff2>; rel=preload; as="font"'
)
def test_proxy_request_injects_hmr_fixer_for_html_response(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
upstream = httpx.Response(
200,
headers={"content-type": "text/html; charset=utf-8"},
content=b"<html><head><title>x</title></head><body></body></html>",
)
monkeypatch.setattr(api, "_get_sandbox_url", lambda *_args: "http://sandbox")
class FakeClient:
def __init__(self, *_args: object, **_kwargs: object) -> None:
pass
def __enter__(self) -> "FakeClient":
return self
def __exit__(self, *_args: object) -> Literal[False]:
return False
def get(self, _url: str, headers: dict[str, str]) -> httpx.Response:
assert "host" not in {key.lower() for key in headers}
return upstream
monkeypatch.setattr(api.httpx, "Client", FakeClient)
request = cast(Request, SimpleNamespace(headers={}, query_params=""))
response = api._proxy_request(
"", request, UUID(SESSION_ID), cast(Session, SimpleNamespace())
)
body = cast(bytes, response.body).decode("utf-8")
assert "window.WebSocket = function (url, protocols)" in body
assert body.index("window.WebSocket = function (url, protocols)") < body.index(
"<title>x</title>"
)
def test_rewrites_absolute_link_header_font_preload_paths(self) -> None:
headers = {
"link": (
"<https://craft-dev.onyx.app/_next/static/media/font.woff2>; "
'rel=preload; as="font"; crossorigin'
)
}
result = _rewrite_proxy_response_headers(headers, SESSION_ID)
assert f"<{BASE}/_next/static/media/font.woff2>" in result["link"]

View File

@@ -1,400 +0,0 @@
from typing import Any
from typing import cast
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
from unittest.mock import patch
from urllib.parse import parse_qs
from urllib.parse import urlparse
from fastapi import FastAPI
from fastapi import Response
from fastapi.testclient import TestClient
from fastapi_users.authentication import AuthenticationBackend
from fastapi_users.authentication import CookieTransport
from fastapi_users.jwt import generate_jwt
from httpx_oauth.oauth2 import BaseOAuth2
from httpx_oauth.oauth2 import GetAccessTokenError
from onyx.auth.users import CSRF_TOKEN_COOKIE_NAME
from onyx.auth.users import CSRF_TOKEN_KEY
from onyx.auth.users import get_oauth_router
from onyx.auth.users import get_pkce_cookie_name
from onyx.auth.users import PKCE_COOKIE_NAME_PREFIX
from onyx.auth.users import STATE_TOKEN_AUDIENCE
from onyx.error_handling.exceptions import register_onyx_exception_handlers
class _StubOAuthClient:
def __init__(self) -> None:
self.name = "openid"
self.authorization_calls: list[dict[str, str | list[str] | None]] = []
self.access_token_calls: list[dict[str, str | None]] = []
async def get_authorization_url(
self,
redirect_uri: str,
state: str | None = None,
scope: list[str] | None = None,
code_challenge: str | None = None,
code_challenge_method: str | None = None,
) -> str:
self.authorization_calls.append(
{
"redirect_uri": redirect_uri,
"state": state,
"scope": scope,
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method,
}
)
return f"https://idp.example.com/authorize?state={state}"
async def get_access_token(
self, code: str, redirect_uri: str, code_verifier: str | None = None
) -> dict[str, str | int]:
self.access_token_calls.append(
{
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier,
}
)
return {
"access_token": "oidc_access_token",
"refresh_token": "oidc_refresh_token",
"expires_at": 1730000000,
}
async def get_id_email(self, _access_token: str) -> tuple[str, str | None]:
return ("oidc_account_id", "oidc_user@example.com")
def _build_test_client(
enable_pkce: bool,
login_status_code: int = 302,
) -> tuple[TestClient, _StubOAuthClient, MagicMock]:
oauth_client = _StubOAuthClient()
transport = CookieTransport(cookie_name="testsession")
async def get_strategy() -> MagicMock:
return MagicMock()
backend = AuthenticationBackend(
name="test_backend",
transport=transport,
get_strategy=get_strategy,
)
login_response = Response(status_code=login_status_code)
if login_status_code in {301, 302, 303, 307, 308}:
login_response.headers["location"] = "/app"
login_response.set_cookie("testsession", "session-token")
backend.login = AsyncMock(return_value=login_response) # type: ignore[method-assign]
user = MagicMock()
user.is_active = True
user_manager = MagicMock()
user_manager.oauth_callback = AsyncMock(return_value=user)
user_manager.on_after_login = AsyncMock()
async def get_user_manager() -> MagicMock:
return user_manager
router = get_oauth_router(
oauth_client=cast(BaseOAuth2[Any], oauth_client),
backend=backend,
get_user_manager=get_user_manager,
state_secret="test-secret",
redirect_url="http://localhost/auth/oidc/callback",
associate_by_email=True,
is_verified_by_default=True,
enable_pkce=enable_pkce,
)
app = FastAPI()
app.include_router(router, prefix="/auth/oidc")
register_onyx_exception_handlers(app)
client = TestClient(app, raise_server_exceptions=False)
return client, oauth_client, user_manager
def _extract_state_from_authorize_response(response: Any) -> str:
auth_url = response.json()["authorization_url"]
return parse_qs(urlparse(auth_url).query)["state"][0]
def test_oidc_authorize_omits_pkce_when_flag_disabled() -> None:
client, oauth_client, _ = _build_test_client(enable_pkce=False)
response = client.get("/auth/oidc/authorize")
assert response.status_code == 200
assert oauth_client.authorization_calls[0]["code_challenge"] is None
assert oauth_client.authorization_calls[0]["code_challenge_method"] is None
assert "fastapiusersoauthcsrf" in response.cookies.keys()
assert not any(
key.startswith(PKCE_COOKIE_NAME_PREFIX) for key in response.cookies.keys()
)
def test_oidc_authorize_adds_pkce_when_flag_enabled() -> None:
client, oauth_client, _ = _build_test_client(enable_pkce=True)
response = client.get("/auth/oidc/authorize")
assert response.status_code == 200
assert oauth_client.authorization_calls[0]["code_challenge"] is not None
assert oauth_client.authorization_calls[0]["code_challenge_method"] == "S256"
assert any(
key.startswith(PKCE_COOKIE_NAME_PREFIX) for key in response.cookies.keys()
)
def test_oidc_callback_fails_when_pkce_cookie_missing() -> None:
client, oauth_client, _ = _build_test_client(enable_pkce=True)
authorize_response = client.get("/auth/oidc/authorize")
state = _extract_state_from_authorize_response(authorize_response)
for key in list(client.cookies.keys()):
if key.startswith(PKCE_COOKIE_NAME_PREFIX):
del client.cookies[key]
response = client.get(
"/auth/oidc/callback", params={"code": "abc123", "state": state}
)
assert response.status_code == 400
assert response.json()["error_code"] == "VALIDATION_ERROR"
assert oauth_client.access_token_calls == []
assert "Max-Age=0" in response.headers.get("set-cookie", "")
def test_oidc_callback_rejects_bad_state_before_token_exchange() -> None:
client, oauth_client, _ = _build_test_client(enable_pkce=True)
client.get("/auth/oidc/authorize")
tampered_state = "not-a-valid-state-jwt"
client.cookies.set(get_pkce_cookie_name(tampered_state), "verifier123")
response = client.get(
"/auth/oidc/callback", params={"code": "abc123", "state": tampered_state}
)
assert response.status_code == 400
assert response.json()["error_code"] == "VALIDATION_ERROR"
assert oauth_client.access_token_calls == []
assert "Max-Age=0" in response.headers.get("set-cookie", "")
def test_oidc_callback_rejects_wrongly_signed_state_before_token_exchange() -> None:
client, oauth_client, _ = _build_test_client(enable_pkce=True)
client.get("/auth/oidc/authorize")
csrf_token = client.cookies.get(CSRF_TOKEN_COOKIE_NAME)
assert csrf_token is not None
tampered_state = generate_jwt(
{
"aud": STATE_TOKEN_AUDIENCE,
CSRF_TOKEN_KEY: csrf_token,
},
"wrong-secret",
3600,
)
client.cookies.set(get_pkce_cookie_name(tampered_state), "verifier123")
response = client.get(
"/auth/oidc/callback",
params={"code": "abc123", "state": tampered_state},
)
assert response.status_code == 400
assert response.json()["error_code"] == "VALIDATION_ERROR"
assert response.json()["detail"] == "ACCESS_TOKEN_DECODE_ERROR"
assert oauth_client.access_token_calls == []
assert "Max-Age=0" in response.headers.get("set-cookie", "")
def test_oidc_callback_rejects_csrf_mismatch_in_pkce_path() -> None:
client, oauth_client, _ = _build_test_client(enable_pkce=True)
authorize_response = client.get("/auth/oidc/authorize")
state = _extract_state_from_authorize_response(authorize_response)
# Keep PKCE verifier cookie intact, but invalidate CSRF match against state JWT.
client.cookies.set("fastapiusersoauthcsrf", "wrong-csrf-token")
response = client.get(
"/auth/oidc/callback",
params={"code": "abc123", "state": state},
)
assert response.status_code == 400
assert response.json()["error_code"] == "VALIDATION_ERROR"
assert oauth_client.access_token_calls == []
assert "Max-Age=0" in response.headers.get("set-cookie", "")
def test_oidc_callback_get_access_token_error_is_400() -> None:
client, oauth_client, _ = _build_test_client(enable_pkce=True)
authorize_response = client.get("/auth/oidc/authorize")
state = _extract_state_from_authorize_response(authorize_response)
with patch.object(
oauth_client,
"get_access_token",
AsyncMock(side_effect=GetAccessTokenError("token exchange failed")),
):
response = client.get(
"/auth/oidc/callback", params={"code": "abc123", "state": state}
)
assert response.status_code == 400
assert response.json()["error_code"] == "VALIDATION_ERROR"
assert response.json()["detail"] == "Authorization code exchange failed"
assert "Max-Age=0" in response.headers.get("set-cookie", "")
def test_oidc_callback_cleans_pkce_cookie_on_idp_error_with_state() -> None:
client, oauth_client, _ = _build_test_client(enable_pkce=True)
authorize_response = client.get("/auth/oidc/authorize")
state = _extract_state_from_authorize_response(authorize_response)
response = client.get(
"/auth/oidc/callback",
params={"error": "access_denied", "state": state},
)
assert response.status_code == 400
assert response.json()["error_code"] == "VALIDATION_ERROR"
assert response.json()["detail"] == "Authorization request failed or was denied"
assert oauth_client.access_token_calls == []
assert "Max-Age=0" in response.headers.get("set-cookie", "")
def test_oidc_callback_cleans_pkce_cookie_on_missing_email() -> None:
client, oauth_client, _ = _build_test_client(enable_pkce=True)
authorize_response = client.get("/auth/oidc/authorize")
state = _extract_state_from_authorize_response(authorize_response)
with patch.object(
oauth_client, "get_id_email", AsyncMock(return_value=("oidc_account_id", None))
):
response = client.get(
"/auth/oidc/callback", params={"code": "abc123", "state": state}
)
assert response.status_code == 400
assert response.json()["error_code"] == "VALIDATION_ERROR"
assert "Max-Age=0" in response.headers.get("set-cookie", "")
def test_oidc_callback_rejects_wrong_audience_state_before_token_exchange() -> None:
client, oauth_client, _ = _build_test_client(enable_pkce=True)
client.get("/auth/oidc/authorize")
csrf_token = client.cookies.get(CSRF_TOKEN_COOKIE_NAME)
assert csrf_token is not None
wrong_audience_state = generate_jwt(
{
"aud": "wrong-audience",
CSRF_TOKEN_KEY: csrf_token,
},
"test-secret",
3600,
)
client.cookies.set(get_pkce_cookie_name(wrong_audience_state), "verifier123")
response = client.get(
"/auth/oidc/callback",
params={"code": "abc123", "state": wrong_audience_state},
)
assert response.status_code == 400
assert response.json()["error_code"] == "VALIDATION_ERROR"
assert response.json()["detail"] == "ACCESS_TOKEN_DECODE_ERROR"
assert oauth_client.access_token_calls == []
assert "Max-Age=0" in response.headers.get("set-cookie", "")
def test_oidc_callback_uses_code_verifier_when_pkce_enabled() -> None:
client, oauth_client, user_manager = _build_test_client(enable_pkce=True)
authorize_response = client.get("/auth/oidc/authorize")
state = _extract_state_from_authorize_response(authorize_response)
with patch(
"onyx.auth.users.fetch_ee_implementation_or_noop",
return_value=lambda _email: "tenant_1",
):
response = client.get(
"/auth/oidc/callback",
params={"code": "abc123", "state": state},
follow_redirects=False,
)
assert response.status_code == 302
assert response.headers.get("location") == "/"
assert oauth_client.access_token_calls[0]["code_verifier"] is not None
user_manager.oauth_callback.assert_awaited_once()
assert "Max-Age=0" in response.headers.get("set-cookie", "")
def test_oidc_callback_works_without_pkce_when_flag_disabled() -> None:
client, oauth_client, user_manager = _build_test_client(enable_pkce=False)
authorize_response = client.get("/auth/oidc/authorize")
state = _extract_state_from_authorize_response(authorize_response)
with patch(
"onyx.auth.users.fetch_ee_implementation_or_noop",
return_value=lambda _email: "tenant_1",
):
response = client.get(
"/auth/oidc/callback",
params={"code": "abc123", "state": state},
follow_redirects=False,
)
assert response.status_code == 302
assert oauth_client.access_token_calls[0]["code_verifier"] is None
user_manager.oauth_callback.assert_awaited_once()
def test_oidc_callback_pkce_preserves_redirect_when_backend_login_is_non_redirect() -> (
None
):
client, oauth_client, user_manager = _build_test_client(
enable_pkce=True,
login_status_code=200,
)
authorize_response = client.get("/auth/oidc/authorize")
state = _extract_state_from_authorize_response(authorize_response)
with patch(
"onyx.auth.users.fetch_ee_implementation_or_noop",
return_value=lambda _email: "tenant_1",
):
response = client.get(
"/auth/oidc/callback",
params={"code": "abc123", "state": state},
follow_redirects=False,
)
assert response.status_code == 302
assert response.headers.get("location") == "/"
assert oauth_client.access_token_calls[0]["code_verifier"] is not None
user_manager.oauth_callback.assert_awaited_once()
assert "Max-Age=0" in response.headers.get("set-cookie", "")
def test_oidc_callback_non_pkce_rejects_csrf_mismatch() -> None:
client, oauth_client, _ = _build_test_client(enable_pkce=False)
authorize_response = client.get("/auth/oidc/authorize")
state = _extract_state_from_authorize_response(authorize_response)
client.cookies.set(CSRF_TOKEN_COOKIE_NAME, "wrong-csrf-token")
response = client.get(
"/auth/oidc/callback",
params={"code": "abc123", "state": state},
)
assert response.status_code == 400
assert response.json()["error_code"] == "VALIDATION_ERROR"
assert response.json()["detail"] == "OAUTH_INVALID_STATE"
# NOTE: In the non-PKCE path, oauth2_authorize_callback exchanges the code
# before route-body CSRF validation runs. This is a known ordering trade-off.
assert oauth_client.access_token_calls

View File

@@ -0,0 +1,112 @@
"""Unit tests for multi-model schema and Pydantic model additions."""
from datetime import datetime
from onyx.configs.constants import MessageType
from onyx.llm.override_models import LLMOverride
from onyx.server.query_and_chat.models import ChatMessageDetail
from onyx.server.query_and_chat.models import MultiModelMessageResponseIDInfo
from onyx.server.query_and_chat.models import SendMessageRequest
from onyx.server.query_and_chat.placement import Placement
def test_placement_model_index_default_none() -> None:
p = Placement(turn_index=0)
assert p.model_index is None
def test_placement_model_index_set() -> None:
p = Placement(turn_index=0, model_index=2)
assert p.model_index == 2
def test_placement_serialization_with_model_index() -> None:
p = Placement(turn_index=1, tab_index=0, model_index=1)
data = p.model_dump()
assert data["model_index"] == 1
restored = Placement(**data)
assert restored.model_index == 1
def test_multi_model_message_response_id_info() -> None:
info = MultiModelMessageResponseIDInfo(
user_message_id=42,
reserved_assistant_message_ids=[100, 101, 102],
model_names=["gpt-4", "claude-3-opus", "gemini-pro"],
)
data = info.model_dump()
assert data["user_message_id"] == 42
assert len(data["reserved_assistant_message_ids"]) == 3
assert len(data["model_names"]) == 3
def test_multi_model_message_response_id_info_null_user() -> None:
info = MultiModelMessageResponseIDInfo(
user_message_id=None,
reserved_assistant_message_ids=[10],
model_names=["gpt-4"],
)
assert info.user_message_id is None
def test_send_message_request_llm_overrides_none_by_default() -> None:
req = SendMessageRequest(
message="hello",
chat_session_id="00000000-0000-0000-0000-000000000001",
)
assert req.llm_overrides is None
assert req.llm_override is None
def test_send_message_request_with_llm_overrides() -> None:
overrides = [
LLMOverride(model_provider="openai", model_version="gpt-4"),
LLMOverride(model_provider="anthropic", model_version="claude-3-opus"),
]
req = SendMessageRequest(
message="compare these",
chat_session_id="00000000-0000-0000-0000-000000000001",
llm_overrides=overrides,
)
assert req.llm_overrides is not None
assert len(req.llm_overrides) == 2
def test_send_message_request_backward_compat_single_override() -> None:
"""Existing single llm_override still works alongside new llm_overrides field."""
req = SendMessageRequest(
message="single model",
chat_session_id="00000000-0000-0000-0000-000000000001",
llm_override=LLMOverride(model_provider="openai", model_version="gpt-4"),
)
assert req.llm_override is not None
assert req.llm_overrides is None
def test_chat_message_detail_multi_model_fields_default_none() -> None:
detail = ChatMessageDetail(
message_id=1,
message="hello",
message_type=MessageType.USER,
time_sent=datetime.now(),
files=[],
)
assert detail.preferred_response_id is None
assert detail.model_display_name is None
def test_chat_message_detail_multi_model_fields_set() -> None:
detail = ChatMessageDetail(
message_id=1,
message="response from gpt-4",
message_type=MessageType.ASSISTANT,
time_sent=datetime.now(),
files=[],
preferred_response_id=42,
model_display_name="GPT-4",
)
assert detail.preferred_response_id == 42
assert detail.model_display_name == "GPT-4"
data = detail.model_dump()
assert data["preferred_response_id"] == 42
assert data["model_display_name"] == "GPT-4"

View File

@@ -1,179 +0,0 @@
"""Unit tests for SharepointConnector._fetch_site_pages 404 handling.
The Graph Pages API returns 404 for classic sites or sites without
modern pages enabled. _fetch_site_pages should gracefully skip these
rather than crashing the entire indexing run.
"""
from __future__ import annotations
from typing import Any
import pytest
from requests import Response
from requests.exceptions import HTTPError
from onyx.connectors.sharepoint.connector import SharepointConnector
from onyx.connectors.sharepoint.connector import SiteDescriptor
SITE_URL = "https://tenant.sharepoint.com/sites/ClassicSite"
FAKE_SITE_ID = "tenant.sharepoint.com,abc123,def456"
def _site_descriptor() -> SiteDescriptor:
return SiteDescriptor(url=SITE_URL, drive_name=None, folder_path=None)
def _make_http_error(status_code: int) -> HTTPError:
response = Response()
response.status_code = status_code
response._content = b'{"error":{"code":"itemNotFound","message":"Item not found"}}'
return HTTPError(response=response)
def _setup_connector(
monkeypatch: pytest.MonkeyPatch, # noqa: ARG001
) -> SharepointConnector:
"""Create a connector with the graph client and site resolution mocked."""
connector = SharepointConnector(sites=[SITE_URL])
connector.graph_api_base = "https://graph.microsoft.com/v1.0"
mock_sites = type(
"FakeSites",
(),
{
"get_by_url": staticmethod(
lambda url: type( # noqa: ARG005
"Q",
(),
{
"execute_query": lambda self: None, # noqa: ARG005
"id": FAKE_SITE_ID,
},
)()
),
},
)()
connector._graph_client = type("FakeGraphClient", (), {"sites": mock_sites})()
return connector
def _patch_graph_api_get_json(
monkeypatch: pytest.MonkeyPatch,
fake_fn: Any,
) -> None:
monkeypatch.setattr(SharepointConnector, "_graph_api_get_json", fake_fn)
class TestFetchSitePages404:
def test_404_yields_no_pages(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A 404 from the Pages API should result in zero yielded pages."""
connector = _setup_connector(monkeypatch)
def fake_get_json(
self: SharepointConnector, # noqa: ARG001
url: str, # noqa: ARG001
params: dict[str, str] | None = None, # noqa: ARG001
) -> dict[str, Any]:
raise _make_http_error(404)
_patch_graph_api_get_json(monkeypatch, fake_get_json)
pages = list(connector._fetch_site_pages(_site_descriptor()))
assert pages == []
def test_404_does_not_raise(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A 404 must not propagate as an exception."""
connector = _setup_connector(monkeypatch)
def fake_get_json(
self: SharepointConnector, # noqa: ARG001
url: str, # noqa: ARG001
params: dict[str, str] | None = None, # noqa: ARG001
) -> dict[str, Any]:
raise _make_http_error(404)
_patch_graph_api_get_json(monkeypatch, fake_get_json)
for _ in connector._fetch_site_pages(_site_descriptor()):
pass
def test_non_404_http_error_still_raises(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Non-404 HTTP errors (e.g. 403) must still propagate."""
connector = _setup_connector(monkeypatch)
def fake_get_json(
self: SharepointConnector, # noqa: ARG001
url: str, # noqa: ARG001
params: dict[str, str] | None = None, # noqa: ARG001
) -> dict[str, Any]:
raise _make_http_error(403)
_patch_graph_api_get_json(monkeypatch, fake_get_json)
with pytest.raises(HTTPError):
list(connector._fetch_site_pages(_site_descriptor()))
def test_successful_fetch_yields_pages(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When the API succeeds, pages should be yielded normally."""
connector = _setup_connector(monkeypatch)
fake_page = {
"id": "page-1",
"title": "Hello World",
"webUrl": f"{SITE_URL}/SitePages/Hello.aspx",
"lastModifiedDateTime": "2025-06-01T00:00:00Z",
}
def fake_get_json(
self: SharepointConnector, # noqa: ARG001
url: str, # noqa: ARG001
params: dict[str, str] | None = None, # noqa: ARG001
) -> dict[str, Any]:
return {"value": [fake_page]}
_patch_graph_api_get_json(monkeypatch, fake_get_json)
pages = list(connector._fetch_site_pages(_site_descriptor()))
assert len(pages) == 1
assert pages[0]["id"] == "page-1"
def test_404_on_second_page_stops_pagination(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""If the first API page succeeds but a nextLink returns 404,
already-yielded pages are kept and iteration stops cleanly."""
connector = _setup_connector(monkeypatch)
call_count = 0
first_page = {
"id": "page-1",
"title": "First",
"webUrl": f"{SITE_URL}/SitePages/First.aspx",
"lastModifiedDateTime": "2025-06-01T00:00:00Z",
}
def fake_get_json(
self: SharepointConnector, # noqa: ARG001
url: str, # noqa: ARG001
params: dict[str, str] | None = None, # noqa: ARG001
) -> dict[str, Any]:
nonlocal call_count
call_count += 1
if call_count == 1:
return {
"value": [first_page],
"@odata.nextLink": "https://graph.microsoft.com/next",
}
raise _make_http_error(404)
_patch_graph_api_get_json(monkeypatch, fake_get_json)
pages = list(connector._fetch_site_pages(_site_descriptor()))
assert len(pages) == 1
assert pages[0]["id"] == "page-1"

View File

@@ -186,42 +186,3 @@ def test_categorize_uploaded_files_checks_size_before_text_extraction(
assert len(result.acceptable) == 0
assert len(result.rejected) == 1
assert result.rejected[0].reason == "Exceeds 1 MB file size limit"
def test_categorize_uploaded_files_accepts_python_file(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_common_dependencies(monkeypatch)
monkeypatch.setattr(utils, "USER_FILE_MAX_UPLOAD_SIZE_BYTES", 10_000)
monkeypatch.setattr(utils, "USER_FILE_MAX_UPLOAD_SIZE_MB", 1)
py_source = b'def hello():\n print("world")\n'
monkeypatch.setattr(
utils, "extract_file_text", lambda **_kwargs: py_source.decode()
)
upload = _make_upload("script.py", size=len(py_source), content=py_source)
result = utils.categorize_uploaded_files([upload], MagicMock())
assert len(result.acceptable) == 1
assert result.acceptable[0].filename == "script.py"
assert len(result.rejected) == 0
def test_categorize_uploaded_files_rejects_binary_file(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_common_dependencies(monkeypatch)
monkeypatch.setattr(utils, "USER_FILE_MAX_UPLOAD_SIZE_BYTES", 10_000)
monkeypatch.setattr(utils, "USER_FILE_MAX_UPLOAD_SIZE_MB", 1)
monkeypatch.setattr(utils, "extract_file_text", lambda **_kwargs: "")
binary_content = bytes(range(256)) * 4
upload = _make_upload("data.bin", size=len(binary_content), content=binary_content)
result = utils.categorize_uploaded_files([upload], MagicMock())
assert len(result.acceptable) == 0
assert len(result.rejected) == 1
assert result.rejected[0].filename == "data.bin"
assert "Unsupported file type" in result.rejected[0].reason

View File

@@ -33,7 +33,6 @@ SECRET=
# OpenID Connect (OIDC)
#OPENID_CONFIG_URL=
#OIDC_PKCE_ENABLED=
# SAML config directory for OneLogin compatible setups
#SAML_CONF_DIR=

View File

@@ -167,7 +167,6 @@ LOG_ONYX_MODEL_INTERACTIONS=False
# OAUTH_CLIENT_ID=
# OAUTH_CLIENT_SECRET=
# OPENID_CONFIG_URL=
# OIDC_PKCE_ENABLED=
# TRACK_EXTERNAL_IDP_EXPIRY=
# CORS_ALLOWED_ORIGIN=
# INTEGRATION_TESTS_MODE=

View File

@@ -5,7 +5,7 @@ home: https://www.onyx.app/
sources:
- "https://github.com/onyx-dot-app/onyx"
type: application
version: 0.4.34
version: 0.4.33
appVersion: latest
annotations:
category: Productivity

View File

@@ -1,6 +0,0 @@
# Values for chart-testing (ct lint/install)
# This file is automatically used by ct when running lint and install commands
auth:
userauth:
values:
user_auth_secret: "placeholder-for-ci-testing"

View File

@@ -1,29 +1,17 @@
{{- if hasKey .Values.auth "secretKeys" }}
{{- fail "ERROR: Secrets handling has been refactored under 'auth' and must be updated before upgrading to this chart version." }}
{{- end }}
{{- range $secretKey, $secretContent := .Values.auth }}
{{- if and (empty $secretContent.existingSecret) (or (not (hasKey $secretContent "enabled")) $secretContent.enabled) }}
{{- $secretName := include "onyx.secretName" $secretContent }}
{{- $existingSecret := lookup "v1" "Secret" $.Release.Namespace $secretName }}
{{- /* Pre-validate: fail before emitting YAML if any required value is missing */ -}}
{{- range $name, $value := $secretContent.values }}
{{- if and (empty $value) (not (and $existingSecret (hasKey $existingSecret.data $name))) }}
{{- fail (printf "Secret value for '%s' is required but not set and no existing secret found. Please set auth.%s.values.%s in values.yaml" $name $secretKey $name) }}
{{- end }}
{{- end }}
{{- range $secretContent := .Values.auth }}
{{- if and (empty $secretContent.existingSecret) (ne ($secretContent.enabled | default true) false) }}
---
apiVersion: v1
kind: Secret
metadata:
name: {{ $secretName }}
name: {{ include "onyx.secretName" $secretContent }}
type: Opaque
stringData:
{{- range $name, $value := $secretContent.values }}
{{- if not (empty $value) }}
{{- range $name, $value := $secretContent.values }}
{{ $name }}: {{ $value | quote }}
{{- else if and $existingSecret (hasKey $existingSecret.data $name) }}
{{ $name }}: {{ index $existingSecret.data $name | b64dec | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -1183,28 +1183,10 @@ auth:
values:
opensearch_admin_username: "admin"
opensearch_admin_password: "OnyxDev1!"
userauth:
# -- Used for signing password reset tokens, email verification tokens, and JWT tokens.
enabled: true
# -- Overwrite the default secret name, ignored if existingSecret is defined
secretName: 'onyx-userauth'
# -- Use a secret specified elsewhere
existingSecret: ""
# -- This defines the env var to secret map
secretKeys:
USER_AUTH_SECRET: user_auth_secret
# -- Secret value. Required - generate with: openssl rand -hex 32
# If not set, helm install/upgrade will fail.
values:
user_auth_secret: ""
configMap:
# Auth type: "basic" (default), "google_oauth", "oidc", or "saml"
# UPGRADE NOTE: Default changed from "disabled" to "basic" in 0.4.34.
# You must also set auth.userauth.values.user_auth_secret.
AUTH_TYPE: "basic"
# Enable PKCE for OIDC login flow. Leave empty/false for backward compatibility.
OIDC_PKCE_ENABLED: ""
# Change this for production uses unless Onyx is only accessible behind VPN
AUTH_TYPE: "disabled"
# 1 Day Default
SESSION_EXPIRE_TIME_SECONDS: "86400"
# Can be something like onyx.app, as an extra double-check

View File

@@ -52,10 +52,6 @@
{
"scope": [],
"content": "Use explicit type annotations for variables to enhance code clarity, especially when moving type hints around in the code."
},
{
"scope": [],
"content": "Use `contributing_guides/best_practices.md` as core review context. Prefer consistency with existing patterns, fix issues in code you touch, avoid tacking new features onto muddy interfaces, fail loudly instead of silently swallowing errors, keep code strictly typed, preserve clear state boundaries, remove duplicate or dead logic, break up overly long functions, avoid hidden import-time side effects, respect module boundaries, and favor correctness-by-construction over relying on callers to use an API correctly."
}
],
"rules": [
@@ -75,14 +71,6 @@
"scope": [],
"rule": "When hardcoding a boolean variable to a constant value, remove the variable entirely and clean up all places where it's used rather than just setting it to a constant."
},
{
"scope": [],
"rule": "Code changes must consider both multi-tenant and single-tenant deployments. In multi-tenant mode, preserve tenant isolation, ensure tenant context is propagated correctly, and avoid assumptions that only hold for a single shared schema or globally shared state. In single-tenant mode, avoid introducing unnecessary tenant-specific requirements or cloud-only control-plane dependencies."
},
{
"scope": [],
"rule": "Code changes must consider both regular Onyx deployments and Onyx lite deployments. Lite deployments disable the vector DB, Redis, model servers, and background workers by default, use PostgreSQL-backed cache/auth/file storage, and rely on the API server to handle background work. Do not assume those services are available unless the code path is explicitly limited to full deployments."
},
{
"scope": ["backend/**/*.py"],
"rule": "Never raise HTTPException directly in business code. Use `raise OnyxError(OnyxErrorCode.XXX, \"message\")` from `onyx.error_handling.exceptions`. A global FastAPI exception handler converts OnyxError into structured JSON responses with {\"error_code\": \"...\", \"message\": \"...\"}. Error codes are defined in `onyx.error_handling.error_codes.OnyxErrorCode`. For upstream errors with dynamic HTTP status codes, use `status_code_override`: `raise OnyxError(OnyxErrorCode.BAD_GATEWAY, detail, status_code_override=upstream_status)`."
@@ -98,21 +86,6 @@
"scope": [],
"path": "CLAUDE.md",
"description": "Project instructions and coding standards"
},
{
"scope": [],
"path": "backend/alembic/README.md",
"description": "Migration guidance, including multi-tenant migration behavior"
},
{
"scope": [],
"path": "deployment/helm/charts/onyx/values-lite.yaml",
"description": "Lite deployment Helm values and service assumptions"
},
{
"scope": [],
"path": "deployment/docker_compose/docker-compose.onyx-lite.yml",
"description": "Lite deployment Docker Compose overlay and disabled service behavior"
}
]
}

View File

@@ -2,6 +2,7 @@ package cmd
import (
"fmt"
"github.com/jmelahman/tag/git"
"github.com/spf13/cobra"
)

124
uv.lock generated
View File

@@ -4739,70 +4739,70 @@ wheels = [
[[package]]
name = "orjson"
version = "3.11.6"
version = "3.11.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/70/a3/4e09c61a5f0c521cba0bb433639610ae037437669f1a4cbc93799e731d78/orjson-3.11.6.tar.gz", hash = "sha256:0a54c72259f35299fd033042367df781c2f66d10252955ca1efb7db309b954cb", size = 6175856, upload-time = "2026-01-29T15:13:07.942Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c6/fe/ed708782d6709cc60eb4c2d8a361a440661f74134675c72990f2c48c785f/orjson-3.11.4.tar.gz", hash = "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d", size = 5945188, upload-time = "2025-10-24T15:50:38.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/fd/d6b0a36854179b93ed77839f107c4089d91cccc9f9ba1b752b6e3bac5f34/orjson-3.11.6-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e259e85a81d76d9665f03d6129e09e4435531870de5961ddcd0bf6e3a7fde7d7", size = 250029, upload-time = "2026-01-29T15:11:35.942Z" },
{ url = "https://files.pythonhosted.org/packages/a3/bb/22902619826641cf3b627c24aab62e2ad6b571bdd1d34733abb0dd57f67a/orjson-3.11.6-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:52263949f41b4a4822c6b1353bcc5ee2f7109d53a3b493501d3369d6d0e7937a", size = 134518, upload-time = "2026-01-29T15:11:37.347Z" },
{ url = "https://files.pythonhosted.org/packages/72/90/7a818da4bba1de711a9653c420749c0ac95ef8f8651cbc1dca551f462fe0/orjson-3.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6439e742fa7834a24698d358a27346bb203bff356ae0402e7f5df8f749c621a8", size = 137917, upload-time = "2026-01-29T15:11:38.511Z" },
{ url = "https://files.pythonhosted.org/packages/59/0f/02846c1cac8e205cb3822dd8aa8f9114acda216f41fd1999ace6b543418d/orjson-3.11.6-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b81ffd68f084b4e993e3867acb554a049fa7787cc8710bbcc1e26965580d99be", size = 134923, upload-time = "2026-01-29T15:11:39.711Z" },
{ url = "https://files.pythonhosted.org/packages/94/cf/aeaf683001b474bb3c3c757073a4231dfdfe8467fceaefa5bfd40902c99f/orjson-3.11.6-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5a5468e5e60f7ef6d7f9044b06c8f94a3c56ba528c6e4f7f06ae95164b595ec", size = 140752, upload-time = "2026-01-29T15:11:41.347Z" },
{ url = "https://files.pythonhosted.org/packages/fc/fe/dad52d8315a65f084044a0819d74c4c9daf9ebe0681d30f525b0d29a31f0/orjson-3.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72c5005eb45bd2535632d4f3bec7ad392832cfc46b62a3021da3b48a67734b45", size = 144201, upload-time = "2026-01-29T15:11:42.537Z" },
{ url = "https://files.pythonhosted.org/packages/36/bc/ab070dd421565b831801077f1e390c4d4af8bfcecafc110336680a33866b/orjson-3.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b14dd49f3462b014455a28a4d810d3549bf990567653eb43765cd847df09145", size = 142380, upload-time = "2026-01-29T15:11:44.309Z" },
{ url = "https://files.pythonhosted.org/packages/e6/d8/4b581c725c3a308717f28bf45a9fdac210bca08b67e8430143699413ff06/orjson-3.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e0bb2c1ea30ef302f0f89f9bf3e7f9ab5e2af29dc9f80eb87aa99788e4e2d65", size = 145582, upload-time = "2026-01-29T15:11:45.506Z" },
{ url = "https://files.pythonhosted.org/packages/5b/a2/09aab99b39f9a7f175ea8fa29adb9933a3d01e7d5d603cdee7f1c40c8da2/orjson-3.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:825e0a85d189533c6bff7e2fc417a28f6fcea53d27125c4551979aecd6c9a197", size = 147270, upload-time = "2026-01-29T15:11:46.782Z" },
{ url = "https://files.pythonhosted.org/packages/b8/2f/5ef8eaf7829dc50da3bf497c7775b21ee88437bc8c41f959aa3504ca6631/orjson-3.11.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:b04575417a26530637f6ab4b1f7b4f666eb0433491091da4de38611f97f2fcf3", size = 421222, upload-time = "2026-01-29T15:11:48.106Z" },
{ url = "https://files.pythonhosted.org/packages/3b/b0/dd6b941294c2b5b13da5fdc7e749e58d0c55a5114ab37497155e83050e95/orjson-3.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b83eb2e40e8c4da6d6b340ee6b1d6125f5195eb1b0ebb7eac23c6d9d4f92d224", size = 155562, upload-time = "2026-01-29T15:11:49.408Z" },
{ url = "https://files.pythonhosted.org/packages/8e/09/43924331a847476ae2f9a16bd6d3c9dab301265006212ba0d3d7fd58763a/orjson-3.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1f42da604ee65a6b87eef858c913ce3e5777872b19321d11e6fc6d21de89b64f", size = 147432, upload-time = "2026-01-29T15:11:50.635Z" },
{ url = "https://files.pythonhosted.org/packages/5d/e9/d9865961081816909f6b49d880749dbbd88425afd7c5bbce0549e2290d77/orjson-3.11.6-cp311-cp311-win32.whl", hash = "sha256:5ae45df804f2d344cffb36c43fdf03c82fb6cd247f5faa41e21891b40dfbf733", size = 139623, upload-time = "2026-01-29T15:11:51.82Z" },
{ url = "https://files.pythonhosted.org/packages/b4/f9/6836edb92f76eec1082919101eb1145d2f9c33c8f2c5e6fa399b82a2aaa8/orjson-3.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:f4295948d65ace0a2d8f2c4ccc429668b7eb8af547578ec882e16bf79b0050b2", size = 136647, upload-time = "2026-01-29T15:11:53.454Z" },
{ url = "https://files.pythonhosted.org/packages/b3/0c/4954082eea948c9ae52ee0bcbaa2f99da3216a71bcc314ab129bde22e565/orjson-3.11.6-cp311-cp311-win_arm64.whl", hash = "sha256:314e9c45e0b81b547e3a1cfa3df3e07a815821b3dac9fe8cb75014071d0c16a4", size = 135327, upload-time = "2026-01-29T15:11:56.616Z" },
{ url = "https://files.pythonhosted.org/packages/14/ba/759f2879f41910b7e5e0cdbd9cf82a4f017c527fb0e972e9869ca7fe4c8e/orjson-3.11.6-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6f03f30cd8953f75f2a439070c743c7336d10ee940da918d71c6f3556af3ddcf", size = 249988, upload-time = "2026-01-29T15:11:58.294Z" },
{ url = "https://files.pythonhosted.org/packages/f0/70/54cecb929e6c8b10104fcf580b0cc7dc551aa193e83787dd6f3daba28bb5/orjson-3.11.6-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:af44baae65ef386ad971469a8557a0673bb042b0b9fd4397becd9c2dfaa02588", size = 134445, upload-time = "2026-01-29T15:11:59.819Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6f/ec0309154457b9ba1ad05f11faa4441f76037152f75e1ac577db3ce7ca96/orjson-3.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c310a48542094e4f7dbb6ac076880994986dda8ca9186a58c3cb70a3514d3231", size = 137708, upload-time = "2026-01-29T15:12:01.488Z" },
{ url = "https://files.pythonhosted.org/packages/20/52/3c71b80840f8bab9cb26417302707b7716b7d25f863f3a541bcfa232fe6e/orjson-3.11.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d8dfa7a5d387f15ecad94cb6b2d2d5f4aeea64efd8d526bfc03c9812d01e1cc0", size = 134798, upload-time = "2026-01-29T15:12:02.705Z" },
{ url = "https://files.pythonhosted.org/packages/30/51/b490a43b22ff736282360bd02e6bded455cf31dfc3224e01cd39f919bbd2/orjson-3.11.6-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba8daee3e999411b50f8b50dbb0a3071dd1845f3f9a1a0a6fa6de86d1689d84d", size = 140839, upload-time = "2026-01-29T15:12:03.956Z" },
{ url = "https://files.pythonhosted.org/packages/95/bc/4bcfe4280c1bc63c5291bb96f98298845b6355da2226d3400e17e7b51e53/orjson-3.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89d104c974eafd7436d7a5fdbc57f7a1e776789959a2f4f1b2eab5c62a339f4", size = 144080, upload-time = "2026-01-29T15:12:05.151Z" },
{ url = "https://files.pythonhosted.org/packages/01/74/22970f9ead9ab1f1b5f8c227a6c3aa8d71cd2c5acd005868a1d44f2362fa/orjson-3.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2e2e2456788ca5ea75616c40da06fc885a7dc0389780e8a41bf7c5389ba257b", size = 142435, upload-time = "2026-01-29T15:12:06.641Z" },
{ url = "https://files.pythonhosted.org/packages/29/34/d564aff85847ab92c82ee43a7a203683566c2fca0723a5f50aebbe759603/orjson-3.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a42efebc45afabb1448001e90458c4020d5c64fbac8a8dc4045b777db76cb5a", size = 145631, upload-time = "2026-01-29T15:12:08.351Z" },
{ url = "https://files.pythonhosted.org/packages/e7/ef/016957a3890752c4aa2368326ea69fa53cdc1fdae0a94a542b6410dbdf52/orjson-3.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71b7cbef8471324966c3738c90ba38775563ef01b512feb5ad4805682188d1b9", size = 147058, upload-time = "2026-01-29T15:12:10.023Z" },
{ url = "https://files.pythonhosted.org/packages/56/cc/9a899c3972085645b3225569f91a30e221f441e5dc8126e6d060b971c252/orjson-3.11.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f8515e5910f454fe9a8e13c2bb9dc4bae4c1836313e967e72eb8a4ad874f0248", size = 421161, upload-time = "2026-01-29T15:12:11.308Z" },
{ url = "https://files.pythonhosted.org/packages/21/a8/767d3fbd6d9b8fdee76974db40619399355fd49bf91a6dd2c4b6909ccf05/orjson-3.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:300360edf27c8c9bf7047345a94fddf3a8b8922df0ff69d71d854a170cb375cf", size = 155757, upload-time = "2026-01-29T15:12:12.776Z" },
{ url = "https://files.pythonhosted.org/packages/ad/0b/205cd69ac87e2272e13ef3f5f03a3d4657e317e38c1b08aaa2ef97060bbc/orjson-3.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:caaed4dad39e271adfadc106fab634d173b2bb23d9cf7e67bd645f879175ebfc", size = 147446, upload-time = "2026-01-29T15:12:14.166Z" },
{ url = "https://files.pythonhosted.org/packages/de/c5/dd9f22aa9f27c54c7d05cc32f4580c9ac9b6f13811eeb81d6c4c3f50d6b1/orjson-3.11.6-cp312-cp312-win32.whl", hash = "sha256:955368c11808c89793e847830e1b1007503a5923ddadc108547d3b77df761044", size = 139717, upload-time = "2026-01-29T15:12:15.7Z" },
{ url = "https://files.pythonhosted.org/packages/23/a1/e62fc50d904486970315a1654b8cfb5832eb46abb18cd5405118e7e1fc79/orjson-3.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:2c68de30131481150073d90a5d227a4a421982f42c025ecdfb66157f9579e06f", size = 136711, upload-time = "2026-01-29T15:12:17.055Z" },
{ url = "https://files.pythonhosted.org/packages/04/3d/b4fefad8bdf91e0fe212eb04975aeb36ea92997269d68857efcc7eb1dda3/orjson-3.11.6-cp312-cp312-win_arm64.whl", hash = "sha256:65dfa096f4e3a5e02834b681f539a87fbe85adc82001383c0db907557f666bfc", size = 135212, upload-time = "2026-01-29T15:12:18.3Z" },
{ url = "https://files.pythonhosted.org/packages/ae/45/d9c71c8c321277bc1ceebf599bc55ba826ae538b7c61f287e9a7e71bd589/orjson-3.11.6-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e4ae1670caabb598a88d385798692ce2a1b2f078971b3329cfb85253c6097f5b", size = 249828, upload-time = "2026-01-29T15:12:20.14Z" },
{ url = "https://files.pythonhosted.org/packages/ac/7e/4afcf4cfa9c2f93846d70eee9c53c3c0123286edcbeb530b7e9bd2aea1b2/orjson-3.11.6-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:2c6b81f47b13dac2caa5d20fbc953c75eb802543abf48403a4703ed3bff225f0", size = 134339, upload-time = "2026-01-29T15:12:22.01Z" },
{ url = "https://files.pythonhosted.org/packages/40/10/6d2b8a064c8d2411d3d0ea6ab43125fae70152aef6bea77bb50fa54d4097/orjson-3.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:647d6d034e463764e86670644bdcaf8e68b076e6e74783383b01085ae9ab334f", size = 137662, upload-time = "2026-01-29T15:12:23.307Z" },
{ url = "https://files.pythonhosted.org/packages/5a/50/5804ea7d586baf83ee88969eefda97a24f9a5bdba0727f73e16305175b26/orjson-3.11.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8523b9cc4ef174ae52414f7699e95ee657c16aa18b3c3c285d48d7966cce9081", size = 134626, upload-time = "2026-01-29T15:12:25.099Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2e/f0492ed43e376722bb4afd648e06cc1e627fc7ec8ff55f6ee739277813ea/orjson-3.11.6-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:313dfd7184cde50c733fc0d5c8c0e2f09017b573afd11dc36bd7476b30b4cb17", size = 140873, upload-time = "2026-01-29T15:12:26.369Z" },
{ url = "https://files.pythonhosted.org/packages/10/15/6f874857463421794a303a39ac5494786ad46a4ab46d92bda6705d78c5aa/orjson-3.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905ee036064ff1e1fd1fb800055ac477cdcb547a78c22c1bc2bbf8d5d1a6fb42", size = 144044, upload-time = "2026-01-29T15:12:28.082Z" },
{ url = "https://files.pythonhosted.org/packages/d2/c7/b7223a3a70f1d0cc2d86953825de45f33877ee1b124a91ca1f79aa6e643f/orjson-3.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce374cb98411356ba906914441fc993f271a7a666d838d8de0e0900dd4a4bc12", size = 142396, upload-time = "2026-01-29T15:12:30.529Z" },
{ url = "https://files.pythonhosted.org/packages/87/e3/aa1b6d3ad3cd80f10394134f73ae92a1d11fdbe974c34aa199cc18bb5fcf/orjson-3.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cded072b9f65fcfd188aead45efa5bd528ba552add619b3ad2a81f67400ec450", size = 145600, upload-time = "2026-01-29T15:12:31.848Z" },
{ url = "https://files.pythonhosted.org/packages/f6/cf/e4aac5a46cbd39d7e769ef8650efa851dfce22df1ba97ae2b33efe893b12/orjson-3.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ab85bdbc138e1f73a234db6bb2e4cc1f0fcec8f4bd2bd2430e957a01aadf746", size = 146967, upload-time = "2026-01-29T15:12:33.203Z" },
{ url = "https://files.pythonhosted.org/packages/0b/04/975b86a4bcf6cfeda47aad15956d52fbeda280811206e9967380fa9355c8/orjson-3.11.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:351b96b614e3c37a27b8ab048239ebc1e0be76cc17481a430d70a77fb95d3844", size = 421003, upload-time = "2026-01-29T15:12:35.097Z" },
{ url = "https://files.pythonhosted.org/packages/28/d1/0369d0baf40eea5ff2300cebfe209883b2473ab4aa4c4974c8bd5ee42bb2/orjson-3.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f9959c85576beae5cdcaaf39510b15105f1ee8b70d5dacd90152617f57be8c83", size = 155695, upload-time = "2026-01-29T15:12:36.589Z" },
{ url = "https://files.pythonhosted.org/packages/ab/1f/d10c6d6ae26ff1d7c3eea6fd048280ef2e796d4fb260c5424fd021f68ecf/orjson-3.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75682d62b1b16b61a30716d7a2ec1f4c36195de4a1c61f6665aedd947b93a5d5", size = 147392, upload-time = "2026-01-29T15:12:37.876Z" },
{ url = "https://files.pythonhosted.org/packages/8d/43/7479921c174441a0aa5277c313732e20713c0969ac303be9f03d88d3db5d/orjson-3.11.6-cp313-cp313-win32.whl", hash = "sha256:40dc277999c2ef227dcc13072be879b4cfd325502daeb5c35ed768f706f2bf30", size = 139718, upload-time = "2026-01-29T15:12:39.274Z" },
{ url = "https://files.pythonhosted.org/packages/88/bc/9ffe7dfbf8454bc4e75bb8bf3a405ed9e0598df1d3535bb4adcd46be07d0/orjson-3.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:f0f6e9f8ff7905660bc3c8a54cd4a675aa98f7f175cf00a59815e2ff42c0d916", size = 136635, upload-time = "2026-01-29T15:12:40.593Z" },
{ url = "https://files.pythonhosted.org/packages/6f/7e/51fa90b451470447ea5023b20d83331ec741ae28d1e6d8ed547c24e7de14/orjson-3.11.6-cp313-cp313-win_arm64.whl", hash = "sha256:1608999478664de848e5900ce41f25c4ecdfc4beacbc632b6fd55e1a586e5d38", size = 135175, upload-time = "2026-01-29T15:12:41.997Z" },
{ url = "https://files.pythonhosted.org/packages/31/9f/46ca908abaeeec7560638ff20276ab327b980d73b3cc2f5b205b4a1c60b3/orjson-3.11.6-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6026db2692041d2a23fe2545606df591687787825ad5821971ef0974f2c47630", size = 249823, upload-time = "2026-01-29T15:12:43.332Z" },
{ url = "https://files.pythonhosted.org/packages/ff/78/ca478089818d18c9cd04f79c43f74ddd031b63c70fa2a946eb5e85414623/orjson-3.11.6-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:132b0ab2e20c73afa85cf142e547511feb3d2f5b7943468984658f3952b467d4", size = 134328, upload-time = "2026-01-29T15:12:45.171Z" },
{ url = "https://files.pythonhosted.org/packages/39/5e/cbb9d830ed4e47f4375ad8eef8e4fff1bf1328437732c3809054fc4e80be/orjson-3.11.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b376fb05f20a96ec117d47987dd3b39265c635725bda40661b4c5b73b77b5fde", size = 137651, upload-time = "2026-01-29T15:12:46.602Z" },
{ url = "https://files.pythonhosted.org/packages/7c/3a/35df6558c5bc3a65ce0961aefee7f8364e59af78749fc796ea255bfa0cf5/orjson-3.11.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:954dae4e080574672a1dfcf2a840eddef0f27bd89b0e94903dd0824e9c1db060", size = 134596, upload-time = "2026-01-29T15:12:47.95Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8e/3d32dd7b7f26a19cc4512d6ed0ae3429567c71feef720fe699ff43c5bc9e/orjson-3.11.6-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe515bb89d59e1e4b48637a964f480b35c0a2676de24e65e55310f6016cca7ce", size = 140923, upload-time = "2026-01-29T15:12:49.333Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9c/1efbf5c99b3304f25d6f0d493a8d1492ee98693637c10ce65d57be839d7b/orjson-3.11.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:380f9709c275917af28feb086813923251e11ee10687257cd7f1ea188bcd4485", size = 144068, upload-time = "2026-01-29T15:12:50.927Z" },
{ url = "https://files.pythonhosted.org/packages/82/83/0d19eeb5be797de217303bbb55dde58dba26f996ed905d301d98fd2d4637/orjson-3.11.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8173e0d3f6081e7034c51cf984036d02f6bab2a2126de5a759d79f8e5a140e7", size = 142493, upload-time = "2026-01-29T15:12:52.432Z" },
{ url = "https://files.pythonhosted.org/packages/32/a7/573fec3df4dc8fc259b7770dc6c0656f91adce6e19330c78d23f87945d1e/orjson-3.11.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dddf9ba706294906c56ef5150a958317b09aa3a8a48df1c52ccf22ec1907eac", size = 145616, upload-time = "2026-01-29T15:12:53.903Z" },
{ url = "https://files.pythonhosted.org/packages/c2/0e/23551b16f21690f7fd5122e3cf40fdca5d77052a434d0071990f97f5fe2f/orjson-3.11.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cbae5c34588dc79938dffb0b6fbe8c531f4dc8a6ad7f39759a9eb5d2da405ef2", size = 146951, upload-time = "2026-01-29T15:12:55.698Z" },
{ url = "https://files.pythonhosted.org/packages/b8/63/5e6c8f39805c39123a18e412434ea364349ee0012548d08aa586e2bd6aa9/orjson-3.11.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f75c318640acbddc419733b57f8a07515e587a939d8f54363654041fd1f4e465", size = 421024, upload-time = "2026-01-29T15:12:57.434Z" },
{ url = "https://files.pythonhosted.org/packages/1d/4d/724975cf0087f6550bd01fd62203418afc0ea33fd099aed318c5bcc52df8/orjson-3.11.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e0ab8d13aa2a3e98b4a43487c9205b2c92c38c054b4237777484d503357c8437", size = 155774, upload-time = "2026-01-29T15:12:59.397Z" },
{ url = "https://files.pythonhosted.org/packages/a8/a3/f4c4e3f46b55db29e0a5f20493b924fc791092d9a03ff2068c9fe6c1002f/orjson-3.11.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f884c7fb1020d44612bd7ac0db0babba0e2f78b68d9a650c7959bf99c783773f", size = 147393, upload-time = "2026-01-29T15:13:00.769Z" },
{ url = "https://files.pythonhosted.org/packages/ee/86/6f5529dd27230966171ee126cecb237ed08e9f05f6102bfaf63e5b32277d/orjson-3.11.6-cp314-cp314-win32.whl", hash = "sha256:8d1035d1b25732ec9f971e833a3e299d2b1a330236f75e6fd945ad982c76aaf3", size = 139760, upload-time = "2026-01-29T15:13:02.173Z" },
{ url = "https://files.pythonhosted.org/packages/d3/b5/91ae7037b2894a6b5002fb33f4fbccec98424a928469835c3837fbb22a9b/orjson-3.11.6-cp314-cp314-win_amd64.whl", hash = "sha256:931607a8865d21682bb72de54231655c86df1870502d2962dbfd12c82890d077", size = 136633, upload-time = "2026-01-29T15:13:04.267Z" },
{ url = "https://files.pythonhosted.org/packages/55/74/f473a3ec7a0a7ebc825ca8e3c86763f7d039f379860c81ba12dcdd456547/orjson-3.11.6-cp314-cp314-win_arm64.whl", hash = "sha256:fe71f6b283f4f1832204ab8235ce07adad145052614f77c876fcf0dac97bc06f", size = 135168, upload-time = "2026-01-29T15:13:05.932Z" },
{ url = "https://files.pythonhosted.org/packages/63/1d/1ea6005fffb56715fd48f632611e163d1604e8316a5bad2288bee9a1c9eb/orjson-3.11.4-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5e59d23cd93ada23ec59a96f215139753fbfe3a4d989549bcb390f8c00370b39", size = 243498, upload-time = "2025-10-24T15:48:48.101Z" },
{ url = "https://files.pythonhosted.org/packages/37/d7/ffed10c7da677f2a9da307d491b9eb1d0125b0307019c4ad3d665fd31f4f/orjson-3.11.4-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5c3aedecfc1beb988c27c79d52ebefab93b6c3921dbec361167e6559aba2d36d", size = 128961, upload-time = "2025-10-24T15:48:49.571Z" },
{ url = "https://files.pythonhosted.org/packages/a2/96/3e4d10a18866d1368f73c8c44b7fe37cc8a15c32f2a7620be3877d4c55a3/orjson-3.11.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da9e5301f1c2caa2a9a4a303480d79c9ad73560b2e7761de742ab39fe59d9175", size = 130321, upload-time = "2025-10-24T15:48:50.713Z" },
{ url = "https://files.pythonhosted.org/packages/eb/1f/465f66e93f434f968dd74d5b623eb62c657bdba2332f5a8be9f118bb74c7/orjson-3.11.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8873812c164a90a79f65368f8f96817e59e35d0cc02786a5356f0e2abed78040", size = 129207, upload-time = "2025-10-24T15:48:52.193Z" },
{ url = "https://files.pythonhosted.org/packages/28/43/d1e94837543321c119dff277ae8e348562fe8c0fafbb648ef7cb0c67e521/orjson-3.11.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5d7feb0741ebb15204e748f26c9638e6665a5fa93c37a2c73d64f1669b0ddc63", size = 136323, upload-time = "2025-10-24T15:48:54.806Z" },
{ url = "https://files.pythonhosted.org/packages/bf/04/93303776c8890e422a5847dd012b4853cdd88206b8bbd3edc292c90102d1/orjson-3.11.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ee5487fefee21e6910da4c2ee9eef005bee568a0879834df86f888d2ffbdd9", size = 137440, upload-time = "2025-10-24T15:48:56.326Z" },
{ url = "https://files.pythonhosted.org/packages/1e/ef/75519d039e5ae6b0f34d0336854d55544ba903e21bf56c83adc51cd8bf82/orjson-3.11.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d40d46f348c0321df01507f92b95a377240c4ec31985225a6668f10e2676f9a", size = 136680, upload-time = "2025-10-24T15:48:57.476Z" },
{ url = "https://files.pythonhosted.org/packages/b5/18/bf8581eaae0b941b44efe14fee7b7862c3382fbc9a0842132cfc7cf5ecf4/orjson-3.11.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95713e5fc8af84d8edc75b785d2386f653b63d62b16d681687746734b4dfc0be", size = 136160, upload-time = "2025-10-24T15:48:59.631Z" },
{ url = "https://files.pythonhosted.org/packages/c4/35/a6d582766d351f87fc0a22ad740a641b0a8e6fc47515e8614d2e4790ae10/orjson-3.11.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad73ede24f9083614d6c4ca9a85fe70e33be7bf047ec586ee2363bc7418fe4d7", size = 140318, upload-time = "2025-10-24T15:49:00.834Z" },
{ url = "https://files.pythonhosted.org/packages/76/b3/5a4801803ab2e2e2d703bce1a56540d9f99a9143fbec7bf63d225044fef8/orjson-3.11.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:842289889de515421f3f224ef9c1f1efb199a32d76d8d2ca2706fa8afe749549", size = 406330, upload-time = "2025-10-24T15:49:02.327Z" },
{ url = "https://files.pythonhosted.org/packages/80/55/a8f682f64833e3a649f620eafefee175cbfeb9854fc5b710b90c3bca45df/orjson-3.11.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3b2427ed5791619851c52a1261b45c233930977e7de8cf36de05636c708fa905", size = 149580, upload-time = "2025-10-24T15:49:03.517Z" },
{ url = "https://files.pythonhosted.org/packages/ad/e4/c132fa0c67afbb3eb88274fa98df9ac1f631a675e7877037c611805a4413/orjson-3.11.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c36e524af1d29982e9b190573677ea02781456b2e537d5840e4538a5ec41907", size = 139846, upload-time = "2025-10-24T15:49:04.761Z" },
{ url = "https://files.pythonhosted.org/packages/54/06/dc3491489efd651fef99c5908e13951abd1aead1257c67f16135f95ce209/orjson-3.11.4-cp311-cp311-win32.whl", hash = "sha256:87255b88756eab4a68ec61837ca754e5d10fa8bc47dc57f75cedfeaec358d54c", size = 135781, upload-time = "2025-10-24T15:49:05.969Z" },
{ url = "https://files.pythonhosted.org/packages/79/b7/5e5e8d77bd4ea02a6ac54c42c818afb01dd31961be8a574eb79f1d2cfb1e/orjson-3.11.4-cp311-cp311-win_amd64.whl", hash = "sha256:e2d5d5d798aba9a0e1fede8d853fa899ce2cb930ec0857365f700dffc2c7af6a", size = 131391, upload-time = "2025-10-24T15:49:07.355Z" },
{ url = "https://files.pythonhosted.org/packages/0f/dc/9484127cc1aa213be398ed735f5f270eedcb0c0977303a6f6ddc46b60204/orjson-3.11.4-cp311-cp311-win_arm64.whl", hash = "sha256:6bb6bb41b14c95d4f2702bce9975fda4516f1db48e500102fc4d8119032ff045", size = 126252, upload-time = "2025-10-24T15:49:08.869Z" },
{ url = "https://files.pythonhosted.org/packages/63/51/6b556192a04595b93e277a9ff71cd0cc06c21a7df98bcce5963fa0f5e36f/orjson-3.11.4-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d4371de39319d05d3f482f372720b841c841b52f5385bd99c61ed69d55d9ab50", size = 243571, upload-time = "2025-10-24T15:49:10.008Z" },
{ url = "https://files.pythonhosted.org/packages/1c/2c/2602392ddf2601d538ff11848b98621cd465d1a1ceb9db9e8043181f2f7b/orjson-3.11.4-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e41fd3b3cac850eaae78232f37325ed7d7436e11c471246b87b2cd294ec94853", size = 128891, upload-time = "2025-10-24T15:49:11.297Z" },
{ url = "https://files.pythonhosted.org/packages/4e/47/bf85dcf95f7a3a12bf223394a4f849430acd82633848d52def09fa3f46ad/orjson-3.11.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600e0e9ca042878c7fdf189cf1b028fe2c1418cc9195f6cb9824eb6ed99cb938", size = 130137, upload-time = "2025-10-24T15:49:12.544Z" },
{ url = "https://files.pythonhosted.org/packages/b4/4d/a0cb31007f3ab6f1fd2a1b17057c7c349bc2baf8921a85c0180cc7be8011/orjson-3.11.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7bbf9b333f1568ef5da42bc96e18bf30fd7f8d54e9ae066d711056add508e415", size = 129152, upload-time = "2025-10-24T15:49:13.754Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ef/2811def7ce3d8576b19e3929fff8f8f0d44bc5eb2e0fdecb2e6e6cc6c720/orjson-3.11.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4806363144bb6e7297b8e95870e78d30a649fdc4e23fc84daa80c8ebd366ce44", size = 136834, upload-time = "2025-10-24T15:49:15.307Z" },
{ url = "https://files.pythonhosted.org/packages/00/d4/9aee9e54f1809cec8ed5abd9bc31e8a9631d19460e3b8470145d25140106/orjson-3.11.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad355e8308493f527d41154e9053b86a5be892b3b359a5c6d5d95cda23601cb2", size = 137519, upload-time = "2025-10-24T15:49:16.557Z" },
{ url = "https://files.pythonhosted.org/packages/db/ea/67bfdb5465d5679e8ae8d68c11753aaf4f47e3e7264bad66dc2f2249e643/orjson-3.11.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a7517482667fb9f0ff1b2f16fe5829296ed7a655d04d68cd9711a4d8a4e708", size = 136749, upload-time = "2025-10-24T15:49:17.796Z" },
{ url = "https://files.pythonhosted.org/packages/01/7e/62517dddcfce6d53a39543cd74d0dccfcbdf53967017c58af68822100272/orjson-3.11.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97eb5942c7395a171cbfecc4ef6701fc3c403e762194683772df4c54cfbb2210", size = 136325, upload-time = "2025-10-24T15:49:19.347Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/40516739f99ab4c7ec3aaa5cc242d341fcb03a45d89edeeaabc5f69cb2cf/orjson-3.11.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:149d95d5e018bdd822e3f38c103b1a7c91f88d38a88aada5c4e9b3a73a244241", size = 140204, upload-time = "2025-10-24T15:49:20.545Z" },
{ url = "https://files.pythonhosted.org/packages/82/18/ff5734365623a8916e3a4037fcef1cd1782bfc14cf0992afe7940c5320bf/orjson-3.11.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:624f3951181eb46fc47dea3d221554e98784c823e7069edb5dbd0dc826ac909b", size = 406242, upload-time = "2025-10-24T15:49:21.884Z" },
{ url = "https://files.pythonhosted.org/packages/e1/43/96436041f0a0c8c8deca6a05ebeaf529bf1de04839f93ac5e7c479807aec/orjson-3.11.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:03bfa548cf35e3f8b3a96c4e8e41f753c686ff3d8e182ce275b1751deddab58c", size = 150013, upload-time = "2025-10-24T15:49:23.185Z" },
{ url = "https://files.pythonhosted.org/packages/1b/48/78302d98423ed8780479a1e682b9aecb869e8404545d999d34fa486e573e/orjson-3.11.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:525021896afef44a68148f6ed8a8bf8375553d6066c7f48537657f64823565b9", size = 139951, upload-time = "2025-10-24T15:49:24.428Z" },
{ url = "https://files.pythonhosted.org/packages/4a/7b/ad613fdcdaa812f075ec0875143c3d37f8654457d2af17703905425981bf/orjson-3.11.4-cp312-cp312-win32.whl", hash = "sha256:b58430396687ce0f7d9eeb3dd47761ca7d8fda8e9eb92b3077a7a353a75efefa", size = 136049, upload-time = "2025-10-24T15:49:25.973Z" },
{ url = "https://files.pythonhosted.org/packages/b9/3c/9cf47c3ff5f39b8350fb21ba65d789b6a1129d4cbb3033ba36c8a9023520/orjson-3.11.4-cp312-cp312-win_amd64.whl", hash = "sha256:c6dbf422894e1e3c80a177133c0dda260f81428f9de16d61041949f6a2e5c140", size = 131461, upload-time = "2025-10-24T15:49:27.259Z" },
{ url = "https://files.pythonhosted.org/packages/c6/3b/e2425f61e5825dc5b08c2a5a2b3af387eaaca22a12b9c8c01504f8614c36/orjson-3.11.4-cp312-cp312-win_arm64.whl", hash = "sha256:d38d2bc06d6415852224fcc9c0bfa834c25431e466dc319f0edd56cca81aa96e", size = 126167, upload-time = "2025-10-24T15:49:28.511Z" },
{ url = "https://files.pythonhosted.org/packages/23/15/c52aa7112006b0f3d6180386c3a46ae057f932ab3425bc6f6ac50431cca1/orjson-3.11.4-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2d6737d0e616a6e053c8b4acc9eccea6b6cce078533666f32d140e4f85002534", size = 243525, upload-time = "2025-10-24T15:49:29.737Z" },
{ url = "https://files.pythonhosted.org/packages/ec/38/05340734c33b933fd114f161f25a04e651b0c7c33ab95e9416ade5cb44b8/orjson-3.11.4-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:afb14052690aa328cc118a8e09f07c651d301a72e44920b887c519b313d892ff", size = 128871, upload-time = "2025-10-24T15:49:31.109Z" },
{ url = "https://files.pythonhosted.org/packages/55/b9/ae8d34899ff0c012039b5a7cb96a389b2476e917733294e498586b45472d/orjson-3.11.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38aa9e65c591febb1b0aed8da4d469eba239d434c218562df179885c94e1a3ad", size = 130055, upload-time = "2025-10-24T15:49:33.382Z" },
{ url = "https://files.pythonhosted.org/packages/33/aa/6346dd5073730451bee3681d901e3c337e7ec17342fb79659ec9794fc023/orjson-3.11.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f2cf4dfaf9163b0728d061bebc1e08631875c51cd30bf47cb9e3293bfbd7dcd5", size = 129061, upload-time = "2025-10-24T15:49:34.935Z" },
{ url = "https://files.pythonhosted.org/packages/39/e4/8eea51598f66a6c853c380979912d17ec510e8e66b280d968602e680b942/orjson-3.11.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89216ff3dfdde0e4070932e126320a1752c9d9a758d6a32ec54b3b9334991a6a", size = 136541, upload-time = "2025-10-24T15:49:36.923Z" },
{ url = "https://files.pythonhosted.org/packages/9a/47/cb8c654fa9adcc60e99580e17c32b9e633290e6239a99efa6b885aba9dbc/orjson-3.11.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9daa26ca8e97fae0ce8aa5d80606ef8f7914e9b129b6b5df9104266f764ce436", size = 137535, upload-time = "2025-10-24T15:49:38.307Z" },
{ url = "https://files.pythonhosted.org/packages/43/92/04b8cc5c2b729f3437ee013ce14a60ab3d3001465d95c184758f19362f23/orjson-3.11.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c8b2769dc31883c44a9cd126560327767f848eb95f99c36c9932f51090bfce9", size = 136703, upload-time = "2025-10-24T15:49:40.795Z" },
{ url = "https://files.pythonhosted.org/packages/aa/fd/d0733fcb9086b8be4ebcfcda2d0312865d17d0d9884378b7cffb29d0763f/orjson-3.11.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1469d254b9884f984026bd9b0fa5bbab477a4bfe558bba6848086f6d43eb5e73", size = 136293, upload-time = "2025-10-24T15:49:42.347Z" },
{ url = "https://files.pythonhosted.org/packages/c2/d7/3c5514e806837c210492d72ae30ccf050ce3f940f45bf085bab272699ef4/orjson-3.11.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:68e44722541983614e37117209a194e8c3ad07838ccb3127d96863c95ec7f1e0", size = 140131, upload-time = "2025-10-24T15:49:43.638Z" },
{ url = "https://files.pythonhosted.org/packages/9c/dd/ba9d32a53207babf65bd510ac4d0faaa818bd0df9a9c6f472fe7c254f2e3/orjson-3.11.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e7805fda9672c12be2f22ae124dcd7b03928d6c197544fe12174b86553f3196", size = 406164, upload-time = "2025-10-24T15:49:45.498Z" },
{ url = "https://files.pythonhosted.org/packages/8e/f9/f68ad68f4af7c7bde57cd514eaa2c785e500477a8bc8f834838eb696a685/orjson-3.11.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:04b69c14615fb4434ab867bf6f38b2d649f6f300af30a6705397e895f7aec67a", size = 149859, upload-time = "2025-10-24T15:49:46.981Z" },
{ url = "https://files.pythonhosted.org/packages/b6/d2/7f847761d0c26818395b3d6b21fb6bc2305d94612a35b0a30eae65a22728/orjson-3.11.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:639c3735b8ae7f970066930e58cf0ed39a852d417c24acd4a25fc0b3da3c39a6", size = 139926, upload-time = "2025-10-24T15:49:48.321Z" },
{ url = "https://files.pythonhosted.org/packages/9f/37/acd14b12dc62db9a0e1d12386271b8661faae270b22492580d5258808975/orjson-3.11.4-cp313-cp313-win32.whl", hash = "sha256:6c13879c0d2964335491463302a6ca5ad98105fc5db3565499dcb80b1b4bd839", size = 136007, upload-time = "2025-10-24T15:49:49.938Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a9/967be009ddf0a1fffd7a67de9c36656b28c763659ef91352acc02cbe364c/orjson-3.11.4-cp313-cp313-win_amd64.whl", hash = "sha256:09bf242a4af98732db9f9a1ec57ca2604848e16f132e3f72edfd3c5c96de009a", size = 131314, upload-time = "2025-10-24T15:49:51.248Z" },
{ url = "https://files.pythonhosted.org/packages/cb/db/399abd6950fbd94ce125cb8cd1a968def95174792e127b0642781e040ed4/orjson-3.11.4-cp313-cp313-win_arm64.whl", hash = "sha256:a85f0adf63319d6c1ba06fb0dbf997fced64a01179cf17939a6caca662bf92de", size = 126152, upload-time = "2025-10-24T15:49:52.922Z" },
{ url = "https://files.pythonhosted.org/packages/25/e3/54ff63c093cc1697e758e4fceb53164dd2661a7d1bcd522260ba09f54533/orjson-3.11.4-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:42d43a1f552be1a112af0b21c10a5f553983c2a0938d2bbb8ecd8bc9fb572803", size = 243501, upload-time = "2025-10-24T15:49:54.288Z" },
{ url = "https://files.pythonhosted.org/packages/ac/7d/e2d1076ed2e8e0ae9badca65bf7ef22710f93887b29eaa37f09850604e09/orjson-3.11.4-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:26a20f3fbc6c7ff2cb8e89c4c5897762c9d88cf37330c6a117312365d6781d54", size = 128862, upload-time = "2025-10-24T15:49:55.961Z" },
{ url = "https://files.pythonhosted.org/packages/9f/37/ca2eb40b90621faddfa9517dfe96e25f5ae4d8057a7c0cdd613c17e07b2c/orjson-3.11.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e3f20be9048941c7ffa8fc523ccbd17f82e24df1549d1d1fe9317712d19938e", size = 130047, upload-time = "2025-10-24T15:49:57.406Z" },
{ url = "https://files.pythonhosted.org/packages/c7/62/1021ed35a1f2bad9040f05fa4cc4f9893410df0ba3eaa323ccf899b1c90a/orjson-3.11.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aac364c758dc87a52e68e349924d7e4ded348dedff553889e4d9f22f74785316", size = 129073, upload-time = "2025-10-24T15:49:58.782Z" },
{ url = "https://files.pythonhosted.org/packages/e8/3f/f84d966ec2a6fd5f73b1a707e7cd876813422ae4bf9f0145c55c9c6a0f57/orjson-3.11.4-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d5c54a6d76e3d741dcc3f2707f8eeb9ba2a791d3adbf18f900219b62942803b1", size = 136597, upload-time = "2025-10-24T15:50:00.12Z" },
{ url = "https://files.pythonhosted.org/packages/32/78/4fa0aeca65ee82bbabb49e055bd03fa4edea33f7c080c5c7b9601661ef72/orjson-3.11.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f28485bdca8617b79d44627f5fb04336897041dfd9fa66d383a49d09d86798bc", size = 137515, upload-time = "2025-10-24T15:50:01.57Z" },
{ url = "https://files.pythonhosted.org/packages/c1/9d/0c102e26e7fde40c4c98470796d050a2ec1953897e2c8ab0cb95b0759fa2/orjson-3.11.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bfc2a484cad3585e4ba61985a6062a4c2ed5c7925db6d39f1fa267c9d166487f", size = 136703, upload-time = "2025-10-24T15:50:02.944Z" },
{ url = "https://files.pythonhosted.org/packages/df/ac/2de7188705b4cdfaf0b6c97d2f7849c17d2003232f6e70df98602173f788/orjson-3.11.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e34dbd508cb91c54f9c9788923daca129fe5b55c5b4eebe713bf5ed3791280cf", size = 136311, upload-time = "2025-10-24T15:50:04.441Z" },
{ url = "https://files.pythonhosted.org/packages/e0/52/847fcd1a98407154e944feeb12e3b4d487a0e264c40191fb44d1269cbaa1/orjson-3.11.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b13c478fa413d4b4ee606ec8e11c3b2e52683a640b006bb586b3041c2ca5f606", size = 140127, upload-time = "2025-10-24T15:50:07.398Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ae/21d208f58bdb847dd4d0d9407e2929862561841baa22bdab7aea10ca088e/orjson-3.11.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:724ca721ecc8a831b319dcd72cfa370cc380db0bf94537f08f7edd0a7d4e1780", size = 406201, upload-time = "2025-10-24T15:50:08.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/55/0789d6de386c8366059db098a628e2ad8798069e94409b0d8935934cbcb9/orjson-3.11.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:977c393f2e44845ce1b540e19a786e9643221b3323dae190668a98672d43fb23", size = 149872, upload-time = "2025-10-24T15:50:10.234Z" },
{ url = "https://files.pythonhosted.org/packages/cc/1d/7ff81ea23310e086c17b41d78a72270d9de04481e6113dbe2ac19118f7fb/orjson-3.11.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e539e382cf46edec157ad66b0b0872a90d829a6b71f17cb633d6c160a223155", size = 139931, upload-time = "2025-10-24T15:50:11.623Z" },
{ url = "https://files.pythonhosted.org/packages/77/92/25b886252c50ed64be68c937b562b2f2333b45afe72d53d719e46a565a50/orjson-3.11.4-cp314-cp314-win32.whl", hash = "sha256:d63076d625babab9db5e7836118bdfa086e60f37d8a174194ae720161eb12394", size = 136065, upload-time = "2025-10-24T15:50:13.025Z" },
{ url = "https://files.pythonhosted.org/packages/63/b8/718eecf0bb7e9d64e4956afaafd23db9f04c776d445f59fe94f54bdae8f0/orjson-3.11.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a54d6635fa3aaa438ae32e8570b9f0de36f3f6562c308d2a2a452e8b0592db1", size = 131310, upload-time = "2025-10-24T15:50:14.46Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bf/def5e25d4d8bfce296a9a7c8248109bf58622c21618b590678f945a2c59c/orjson-3.11.4-cp314-cp314-win_arm64.whl", hash = "sha256:78b999999039db3cf58f6d230f524f04f75f129ba3d1ca2ed121f8657e575d3d", size = 126151, upload-time = "2025-10-24T15:50:15.878Z" },
]
[[package]]

View File

@@ -53,8 +53,6 @@ const sharedConfig = {
// Testing & Mocking
"msw",
"until-async",
// Language Detection
"linguist-languages",
// Markdown & Syntax Highlighting
"react-markdown",
"remark-.*", // All remark packages

View File

@@ -55,11 +55,8 @@ type OpenButtonContentProps =
children?: string;
};
type OpenButtonVariant = "select-light" | "select-heavy" | "select-tinted";
type OpenButtonProps = Omit<InteractiveStatefulProps, "variant"> & {
variant?: OpenButtonVariant;
} & OpenButtonContentProps & {
type OpenButtonProps = Omit<InteractiveStatefulProps, "variant"> &
OpenButtonContentProps & {
/**
* Size preset — controls gap, text size, and Container height/rounding.
*/
@@ -68,13 +65,6 @@ type OpenButtonProps = Omit<InteractiveStatefulProps, "variant"> & {
/** Width preset. */
width?: WidthVariant;
/**
* Content justify mode. When `"between"`, icon+label group left and
* chevron pushes to the right edge. Default keeps all items in a
* tight `gap-1` row.
*/
justifyContent?: "between";
/** Tooltip text shown on hover. */
tooltip?: string;
@@ -92,11 +82,9 @@ function OpenButton({
size = "lg",
foldable,
width,
justifyContent,
tooltip,
tooltipSide = "top",
interaction,
variant = "select-heavy",
...statefulProps
}: OpenButtonProps) {
const { isDisabled } = useDisabled();
@@ -123,7 +111,7 @@ function OpenButton({
const button = (
<Interactive.Stateful
variant={variant}
variant="select-heavy"
interaction={resolvedInteraction}
{...statefulProps}
>
@@ -137,32 +125,19 @@ function OpenButton({
>
<div
className={cn(
"opal-button interactive-foreground flex flex-row items-center",
justifyContent === "between" ? "w-full justify-between" : "gap-1",
foldable &&
justifyContent !== "between" &&
"interactive-foldable-host"
"opal-button interactive-foreground flex flex-row items-center gap-1",
foldable && "interactive-foldable-host"
)}
>
{justifyContent === "between" ? (
<>
<span className="flex flex-row items-center gap-1">
{iconWrapper(Icon, size, !foldable && !!children)}
{labelEl}
</span>
{iconWrapper(Icon, size, !foldable && !!children)}
{foldable ? (
<Interactive.Foldable>
{labelEl}
{iconWrapper(ChevronIcon, size, !!children)}
</>
) : foldable ? (
<>
{iconWrapper(Icon, size, !foldable && !!children)}
<Interactive.Foldable>
{labelEl}
{iconWrapper(ChevronIcon, size, !!children)}
</Interactive.Foldable>
</>
</Interactive.Foldable>
) : (
<>
{iconWrapper(Icon, size, !foldable && !!children)}
{labelEl}
{iconWrapper(ChevronIcon, size, !!children)}
</>

View File

@@ -9,8 +9,6 @@ import { cn } from "@opal/utils";
type TagColor = "green" | "purple" | "blue" | "gray" | "amber";
type TagSize = "sm" | "md";
interface TagProps {
/** Optional icon component. */
icon?: IconFunctionComponent;
@@ -20,9 +18,6 @@ interface TagProps {
/** Color variant. Default: `"gray"`. */
color?: TagColor;
/** Size variant. Default: `"sm"`. */
size?: TagSize;
}
// ---------------------------------------------------------------------------
@@ -41,11 +36,11 @@ const COLOR_CONFIG: Record<TagColor, { bg: string; text: string }> = {
// Tag
// ---------------------------------------------------------------------------
function Tag({ icon: Icon, title, color = "gray", size = "sm" }: TagProps) {
function Tag({ icon: Icon, title, color = "gray" }: TagProps) {
const config = COLOR_CONFIG[color];
return (
<div className={cn("opal-auxiliary-tag", config.bg)} data-size={size}>
<div className={cn("opal-auxiliary-tag", config.bg)}>
{Icon && (
<div className="opal-auxiliary-tag-icon-container">
<Icon className={cn("opal-auxiliary-tag-icon", config.text)} />
@@ -53,8 +48,7 @@ function Tag({ icon: Icon, title, color = "gray", size = "sm" }: TagProps) {
)}
<span
className={cn(
"opal-auxiliary-tag-title px-[2px]",
size === "md" ? "font-secondary-body" : "font-figure-small-value",
"opal-auxiliary-tag-title px-[2px] font-figure-small-value",
config.text
)}
>
@@ -64,4 +58,4 @@ function Tag({ icon: Icon, title, color = "gray", size = "sm" }: TagProps) {
);
}
export { Tag, type TagProps, type TagColor, type TagSize };
export { Tag, type TagProps, type TagColor };

View File

@@ -13,12 +13,6 @@
gap: 0;
}
.opal-auxiliary-tag[data-size="md"] {
height: 1.375rem;
padding: 0 0.375rem;
border-radius: 0.375rem;
}
.opal-auxiliary-tag-icon-container {
display: flex;
align-items: center;

View File

@@ -10,11 +10,7 @@ import type { WithoutStyles } from "@opal/types";
// Types
// ---------------------------------------------------------------------------
type InteractiveStatefulVariant =
| "select-light"
| "select-heavy"
| "select-tinted"
| "sidebar";
type InteractiveStatefulVariant = "select-light" | "select-heavy" | "sidebar";
type InteractiveStatefulState = "empty" | "filled" | "selected";
type InteractiveStatefulInteraction = "rest" | "hover" | "active";

View File

@@ -11,7 +11,7 @@
Children read the variables with no independent transitions.
State dimension: `data-interactive-state` = "empty" | "filled" | "selected"
Variant dimension: `data-interactive-variant` = "select-light" | "select-heavy" | "select-tinted" | "sidebar"
Variant dimension: `data-interactive-variant` = "select-light" | "select-heavy" | "sidebar"
Interaction override: `data-interaction="hover"` and `data-interaction="active"`
allow JS-controlled visual state overrides.
@@ -211,103 +211,6 @@
--interactive-foreground-icon: var(--action-link-03);
}
/* ===========================================================================
Select-Tinted — like Select-Heavy but with a tinted rest background
=========================================================================== */
/* ---------------------------------------------------------------------------
Select-Tinted — Empty
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="empty"] {
@apply bg-background-tint-01;
--interactive-foreground: var(--text-04);
--interactive-foreground-icon: var(--text-03);
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="empty"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="empty"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-02;
--interactive-foreground-icon: var(--text-04);
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="empty"]:active:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="empty"][data-interaction="active"]:not(
[data-disabled]
) {
@apply bg-background-neutral-00;
--interactive-foreground: var(--text-05);
--interactive-foreground-icon: var(--text-05);
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="empty"][data-disabled] {
@apply bg-transparent;
--interactive-foreground: var(--text-01);
--interactive-foreground-icon: var(--text-01);
}
/* ---------------------------------------------------------------------------
Select-Tinted — Filled
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="filled"] {
@apply bg-background-tint-01;
--interactive-foreground: var(--action-link-05);
--interactive-foreground-icon: var(--action-link-05);
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="filled"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="filled"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-02;
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="filled"]:active:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="filled"][data-interaction="active"]:not(
[data-disabled]
) {
@apply bg-background-tint-00;
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="filled"][data-disabled] {
@apply bg-transparent;
--interactive-foreground: var(--text-01);
--interactive-foreground-icon: var(--text-01);
}
/* ---------------------------------------------------------------------------
Select-Tinted — Selected
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="selected"] {
@apply bg-[var(--action-link-01)];
--interactive-foreground: var(--action-link-05);
--interactive-foreground-icon: var(--action-link-05);
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="selected"]:hover:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="selected"][data-interaction="hover"]:not(
[data-disabled]
) {
@apply bg-background-tint-02;
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="selected"]:active:not(
[data-disabled]
),
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="selected"][data-interaction="active"]:not(
[data-disabled]
) {
@apply bg-background-tint-00;
}
.interactive[data-interactive-variant="select-tinted"][data-interactive-state="selected"][data-disabled] {
@apply bg-transparent;
--interactive-foreground: var(--action-link-03);
--interactive-foreground-icon: var(--action-link-03);
}
/* ===========================================================================
Sidebar
=========================================================================== */

View File

@@ -1,21 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgFilterPlus = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M9.5 12.5L6.83334 11.1667V7.80667L1.5 1.5H14.8333L12.1667 4.65333M12.1667 7V9.5M12.1667 9.5V12M12.1667 9.5H9.66667M12.1667 9.5H14.6667"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgFilterPlus;

View File

@@ -72,7 +72,6 @@ export { default as SvgFileChartPie } from "@opal/icons/file-chart-pie";
export { default as SvgFileSmall } from "@opal/icons/file-small";
export { default as SvgFileText } from "@opal/icons/file-text";
export { default as SvgFilter } from "@opal/icons/filter";
export { default as SvgFilterPlus } from "@opal/icons/filter-plus";
export { default as SvgFold } from "@opal/icons/fold";
export { default as SvgFolder } from "@opal/icons/folder";
export { default as SvgFolderIn } from "@opal/icons/folder-in";

16
web/package-lock.json generated
View File

@@ -59,7 +59,6 @@
"lowlight": "^3.3.0",
"lucide-react": "^0.454.0",
"mdast-util-find-and-replace": "^3.0.1",
"mime": "^4.1.0",
"motion": "^12.29.0",
"next": "16.1.6",
"next-themes": "^0.4.4",
@@ -13884,21 +13883,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mime": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
"integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==",
"funding": [
"https://github.com/sponsors/broofa"
],
"license": "MIT",
"bin": {
"mime": "bin/cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"license": "MIT",

View File

@@ -77,7 +77,6 @@
"lowlight": "^3.3.0",
"lucide-react": "^0.454.0",
"mdast-util-find-and-replace": "^3.0.1",
"mime": "^4.1.0",
"motion": "^12.29.0",
"next": "16.1.6",
"next-themes": "^0.4.4",

View File

@@ -1 +1,342 @@
export { default } from "@/refresh-pages/admin/UsersPage";
"use client";
import { useState } from "react";
import SimpleTabs from "@/refresh-components/SimpleTabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import InvitedUserTable from "@/components/admin/users/InvitedUserTable";
import SignedUpUserTable from "@/components/admin/users/SignedUpUserTable";
import Modal from "@/refresh-components/Modal";
import { ThreeDotsLoader } from "@/components/Loading";
import { toast } from "@/hooks/useToast";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { errorHandlingFetcher } from "@/lib/fetcher";
import useSWR, { mutate } from "swr";
import { ErrorCallout } from "@/components/ErrorCallout";
import BulkAdd, { EmailInviteStatus } from "@/components/admin/users/BulkAdd";
import Text from "@/refresh-components/texts/Text";
import { InvitedUserSnapshot } from "@/lib/types";
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
import PendingUsersTable from "@/components/admin/users/PendingUsersTable";
import CreateButton from "@/refresh-components/buttons/CreateButton";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import { Spinner } from "@/components/Spinner";
import { SvgDownloadCloud, SvgUserPlus } from "@opal/icons";
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.USERS]!;
interface CountDisplayProps {
label: string;
value: number | null;
isLoading: boolean;
}
function CountDisplay({ label, value, isLoading }: CountDisplayProps) {
const displayValue = isLoading
? "..."
: value === null
? "-"
: value.toLocaleString();
return (
<div className="flex items-center gap-1 px-1 py-0.5 rounded-06">
<Text as="p" mainUiMuted text03>
{label}
</Text>
<Text as="p" headingH3 text05>
{displayValue}
</Text>
</div>
);
}
function UsersTables({
q,
isDownloadingUsers,
setIsDownloadingUsers,
}: {
q: string;
isDownloadingUsers: boolean;
setIsDownloadingUsers: (loading: boolean) => void;
}) {
const [currentUsersCount, setCurrentUsersCount] = useState<number | null>(
null
);
const [currentUsersLoading, setCurrentUsersLoading] = useState<boolean>(true);
const downloadAllUsers = async () => {
setIsDownloadingUsers(true);
const startTime = Date.now();
const minDurationMsForSpinner = 1000;
try {
const response = await fetch("/api/manage/users/download");
if (!response.ok) {
throw new Error("Failed to download all users");
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const anchor_tag = document.createElement("a");
anchor_tag.href = url;
anchor_tag.download = "users.csv";
document.body.appendChild(anchor_tag);
anchor_tag.click();
//Clean up URL after download to avoid memory leaks
window.URL.revokeObjectURL(url);
document.body.removeChild(anchor_tag);
} catch (error) {
toast.error(`Failed to download all users - ${error}`);
} finally {
//Ensure spinner is visible for at least 1 second
//This is to avoid the spinner disappearing too quickly
const endTime = Date.now();
const duration = endTime - startTime;
await new Promise((resolve) =>
setTimeout(resolve, minDurationMsForSpinner - duration)
);
setIsDownloadingUsers(false);
}
};
const {
data: invitedUsers,
error: invitedUsersError,
isLoading: invitedUsersLoading,
mutate: invitedUsersMutate,
} = useSWR<InvitedUserSnapshot[]>(
"/api/manage/users/invited",
errorHandlingFetcher
);
const { data: validDomains, error: domainsError } = useSWR<string[]>(
"/api/manage/admin/valid-domains",
errorHandlingFetcher
);
const {
data: pendingUsers,
error: pendingUsersError,
isLoading: pendingUsersLoading,
mutate: pendingUsersMutate,
} = useSWR<InvitedUserSnapshot[]>(
NEXT_PUBLIC_CLOUD_ENABLED ? "/api/tenants/users/pending" : null,
errorHandlingFetcher
);
const invitedUsersCount =
invitedUsers === undefined ? null : invitedUsers.length;
const pendingUsersCount =
pendingUsers === undefined ? null : pendingUsers.length;
// Show loading animation only during the initial data fetch
if (!validDomains) {
return <ThreeDotsLoader />;
}
if (domainsError) {
return (
<ErrorCallout
errorTitle="Error loading valid domains"
errorMsg={domainsError?.info?.detail}
/>
);
}
const tabs = SimpleTabs.generateTabs({
current: {
name: "Current Users",
content: (
<Card className="w-full">
<CardHeader>
<div className="flex justify-between items-center gap-1">
<CardTitle>Current Users</CardTitle>
<Disabled disabled={isDownloadingUsers}>
<Button
icon={SvgDownloadCloud}
onClick={() => downloadAllUsers()}
>
{isDownloadingUsers ? "Downloading..." : "Download CSV"}
</Button>
</Disabled>
</div>
</CardHeader>
<CardContent>
<SignedUpUserTable
invitedUsers={invitedUsers || []}
q={q}
invitedUsersMutate={invitedUsersMutate}
countDisplay={
<CountDisplay
label="Total users"
value={currentUsersCount}
isLoading={currentUsersLoading}
/>
}
onTotalItemsChange={(count) => setCurrentUsersCount(count)}
onLoadingChange={(loading) => {
setCurrentUsersLoading(loading);
if (loading) {
setCurrentUsersCount(null);
}
}}
/>
</CardContent>
</Card>
),
},
invited: {
name: "Invited Users",
content: (
<Card className="w-full">
<CardHeader>
<div className="flex justify-between items-center gap-1">
<CardTitle>Invited Users</CardTitle>
<CountDisplay
label="Total invited"
value={invitedUsersCount}
isLoading={invitedUsersLoading}
/>
</div>
</CardHeader>
<CardContent>
<InvitedUserTable
users={invitedUsers || []}
mutate={invitedUsersMutate}
error={invitedUsersError}
isLoading={invitedUsersLoading}
q={q}
/>
</CardContent>
</Card>
),
},
...(NEXT_PUBLIC_CLOUD_ENABLED && {
pending: {
name: "Pending Users",
content: (
<Card>
<CardHeader>
<div className="flex justify-between items-center gap-1">
<CardTitle>Pending Users</CardTitle>
<CountDisplay
label="Total pending"
value={pendingUsersCount}
isLoading={pendingUsersLoading}
/>
</div>
</CardHeader>
<CardContent>
<PendingUsersTable
users={pendingUsers || []}
mutate={pendingUsersMutate}
error={pendingUsersError}
isLoading={pendingUsersLoading}
q={q}
/>
</CardContent>
</Card>
),
},
}),
});
return <SimpleTabs tabs={tabs} defaultValue="current" />;
}
function SearchableTables() {
const [query, setQuery] = useState("");
const [isDownloadingUsers, setIsDownloadingUsers] = useState(false);
return (
<div>
{isDownloadingUsers && <Spinner />}
<div className="flex flex-col gap-y-4">
<div className="flex flex-row items-center gap-2">
<InputTypeIn
placeholder="Search"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<AddUserButton />
</div>
<UsersTables
q={query}
isDownloadingUsers={isDownloadingUsers}
setIsDownloadingUsers={setIsDownloadingUsers}
/>
</div>
</div>
);
}
function AddUserButton() {
const [bulkAddUsersModal, setBulkAddUsersModal] = useState(false);
const onSuccess = (emailInviteStatus: EmailInviteStatus) => {
mutate(
(key) => typeof key === "string" && key.startsWith("/api/manage/users")
);
setBulkAddUsersModal(false);
if (emailInviteStatus === "NOT_CONFIGURED") {
toast.warning(
"Users added, but no email notification was sent. There is no SMTP server set up for email sending."
);
} else if (emailInviteStatus === "SEND_FAILED") {
toast.warning(
"Users added, but email sending failed. Check your SMTP configuration and try again."
);
} else {
toast.success("Users invited!");
}
};
const onFailure = async (res: Response) => {
const error = (await res.json()).detail;
toast.error(`Failed to invite users - ${error}`);
};
const handleInviteClick = () => {
setBulkAddUsersModal(true);
};
return (
<>
<CreateButton primary onClick={handleInviteClick}>
Invite Users
</CreateButton>
{bulkAddUsersModal && (
<Modal open onOpenChange={() => setBulkAddUsersModal(false)}>
<Modal.Content>
<Modal.Header
icon={SvgUserPlus}
title="Bulk Add Users"
onClose={() => setBulkAddUsersModal(false)}
/>
<Modal.Body>
<div className="flex flex-col gap-2">
<Text as="p">
Add the email addresses to import, separated by whitespaces.
Invited users will be able to login to this domain with their
email address.
</Text>
<BulkAdd onSuccess={onSuccess} onFailure={onFailure} />
</div>
</Modal.Body>
</Modal.Content>
</Modal>
)}
</>
);
}
export default function Page() {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header title={route.title} icon={route.icon} separator />
<SettingsLayouts.Body>
<SearchableTables />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
);
}

View File

@@ -0,0 +1 @@
export { default } from "@/refresh-pages/admin/UsersPage";

View File

@@ -12,7 +12,7 @@ import {
MODAL_ROOT_ID,
} from "@/lib/constants";
import { Metadata } from "next";
import { buildClientUrl } from "@/lib/utilsSS";
import { Inter } from "next/font/google";
import { EnterpriseSettings, ApplicationStatus } from "@/interfaces/settings";
import AppProvider from "@/providers/AppProvider";
@@ -45,14 +45,14 @@ const hankenGrotesk = Hanken_Grotesk({
});
export async function generateMetadata(): Promise<Metadata> {
let logoLocation = "/onyx.ico";
let logoLocation = buildClientUrl("/onyx.ico");
let enterpriseSettings: EnterpriseSettings | null = null;
if (SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED) {
enterpriseSettings = await (await fetchEnterpriseSettingsSS()).json();
logoLocation =
enterpriseSettings && enterpriseSettings.use_custom_logo
? "/api/enterprise-settings/logo"
: "/onyx.ico";
: buildClientUrl("/onyx.ico");
}
return {

View File

@@ -31,6 +31,7 @@ const SETTINGS_LAYOUT_PREFIXES = [
ADMIN_PATHS.LLM_MODELS,
ADMIN_PATHS.AGENTS,
ADMIN_PATHS.USERS,
ADMIN_PATHS.USERS_V2,
ADMIN_PATHS.TOKEN_RATE_LIMITS,
ADMIN_PATHS.SEARCH_SETTINGS,
ADMIN_PATHS.DOCUMENT_PROCESSING,

View File

@@ -11,13 +11,14 @@ import rehypeHighlight from "rehype-highlight";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import "katex/dist/katex.min.css";
import { cn, transformLinkUri } from "@/lib/utils";
import { transformLinkUri } from "@/lib/utils";
type MinimalMarkdownComponentOverrides = Partial<Components>;
interface MinimalMarkdownProps {
content: string;
className?: string;
style?: CSSProperties;
showHeader?: boolean;
/**
* Override specific markdown renderers.
@@ -29,6 +30,7 @@ interface MinimalMarkdownProps {
export default function MinimalMarkdown({
content,
className = "",
style,
showHeader = true,
components,
}: MinimalMarkdownProps) {
@@ -61,17 +63,19 @@ export default function MinimalMarkdown({
}, [content, components, showHeader]);
return (
<ReactMarkdown
className={cn(
"prose dark:prose-invert max-w-full text-sm break-words",
className
)}
components={markdownComponents}
rehypePlugins={[rehypeHighlight, rehypeKatex]}
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
urlTransform={transformLinkUri}
>
{content}
</ReactMarkdown>
<div style={style || {}} className={`${className}`}>
<ReactMarkdown
className="prose dark:prose-invert max-w-full text-sm break-words"
components={markdownComponents}
rehypePlugins={[rehypeHighlight, rehypeKatex]}
remarkPlugins={[
remarkGfm,
[remarkMath, { singleDollarTextMath: false }],
]}
urlTransform={transformLinkUri}
>
{content}
</ReactMarkdown>
</div>
);
}

View File

@@ -136,14 +136,13 @@ function HorizontalInputLayout({
justifyContent="between"
alignItems={center ? "center" : "start"}
>
<div className="flex flex-col flex-1 min-w-0 self-stretch">
<div className="flex flex-col flex-1 self-stretch">
<Content
title={title}
description={description}
optional={optional}
sizePreset={sizePreset}
variant="section"
widthVariant="full"
/>
</div>
<div className="flex flex-col items-end">{children}</div>

View File

@@ -58,6 +58,7 @@ export const ADMIN_PATHS = {
DOCUMENT_PROCESSING: "/admin/configuration/document-processing",
KNOWLEDGE_GRAPH: "/admin/kg",
USERS: "/admin/users",
USERS_V2: "/admin/users2",
API_KEYS: "/admin/api-key",
TOKEN_RATE_LIMITS: "/admin/token-rate-limits",
USAGE: "/admin/performance/usage",
@@ -187,9 +188,14 @@ export const ADMIN_ROUTE_CONFIG: Record<string, AdminRouteConfig> = {
},
[ADMIN_PATHS.USERS]: {
icon: SvgUser,
title: "Users & Requests",
title: "Manage Users",
sidebarLabel: "Users",
},
[ADMIN_PATHS.USERS_V2]: {
icon: SvgUser,
title: "Users & Requests",
sidebarLabel: "Users v2",
},
[ADMIN_PATHS.API_KEYS]: {
icon: SvgKey,
title: "API Keys",

View File

@@ -1,102 +0,0 @@
import {
getCodeLanguage,
getDataLanguage,
getLanguageByMime,
isMarkdownFile,
} from "./languages";
describe("getCodeLanguage", () => {
it.each([
["app.py", "python"],
["index.ts", "typescript"],
["main.go", "go"],
["style.css", "css"],
["page.html", "html"],
["App.vue", "vue"],
["lib.rs", "rust"],
["main.cpp", "c++"],
["util.c", "c"],
["script.js", "javascript"],
])("%s → %s", (filename, expected) => {
expect(getCodeLanguage(filename)).toBe(expected);
});
it.each([
[".h", "c"],
[".inc", "php"],
[".m", "objective-c"],
[".re", "reason"],
])("override: %s → %s", (ext, expected) => {
expect(getCodeLanguage(`file${ext}`)).toBe(expected);
});
it("resolves by exact filename when there is no extension", () => {
expect(getCodeLanguage("Dockerfile")).toBe("dockerfile");
expect(getCodeLanguage("Makefile")).toBe("makefile");
});
it("is case-insensitive for filenames", () => {
expect(getCodeLanguage("INDEX.JS")).toBe("javascript");
expect(getCodeLanguage("dockerfile")).toBe("dockerfile");
});
it("returns null for unknown extensions", () => {
expect(getCodeLanguage("file.xyz123")).toBeNull();
});
it("excludes markdown extensions", () => {
expect(getCodeLanguage("README.md")).toBeNull();
expect(getCodeLanguage("notes.markdown")).toBeNull();
});
});
describe("getDataLanguage", () => {
it.each([
["config.json", "json"],
["config.yaml", "yaml"],
["config.yml", "yaml"],
["config.toml", "toml"],
["data.xml", "xml"],
["data.csv", "csv"],
])("%s → %s", (filename, expected) => {
expect(getDataLanguage(filename)).toBe(expected);
});
it("returns null for code files", () => {
expect(getDataLanguage("app.py")).toBeNull();
expect(getDataLanguage("header.h")).toBeNull();
expect(getDataLanguage("view.m")).toBeNull();
expect(getDataLanguage("component.re")).toBeNull();
});
});
describe("isMarkdownFile", () => {
it("recognises markdown extensions", () => {
expect(isMarkdownFile("README.md")).toBe(true);
expect(isMarkdownFile("doc.markdown")).toBe(true);
});
it("is case-insensitive", () => {
expect(isMarkdownFile("NOTES.MD")).toBe(true);
});
it("rejects non-markdown files", () => {
expect(isMarkdownFile("app.py")).toBe(false);
expect(isMarkdownFile("data.json")).toBe(false);
});
});
describe("getLanguageByMime", () => {
it("resolves known MIME types", () => {
expect(getLanguageByMime("text/x-python")).toBe("python");
expect(getLanguageByMime("text/javascript")).toBe("javascript");
});
it("strips parameters before matching", () => {
expect(getLanguageByMime("text/x-python; charset=utf-8")).toBe("python");
});
it("returns null for unknown MIME types", () => {
expect(getLanguageByMime("application/x-unknown-thing")).toBeNull();
});
});

View File

@@ -7,7 +7,6 @@ interface LinguistLanguage {
type: string;
extensions?: string[];
filenames?: string[];
codemirrorMimeType?: string;
}
interface LanguageMaps {
@@ -15,23 +14,7 @@ interface LanguageMaps {
filenames: Map<string, string>;
}
// Explicit winners for extensions claimed by multiple linguist-languages entries
// where the "most extensions" heuristic below picks the wrong language.
const EXTENSION_OVERRIDES: Record<string, string> = {
".h": "c",
".inc": "php",
".m": "objective-c",
".re": "reason",
".rs": "rust",
};
// Sort so that languages with more extensions (i.e. more general-purpose) win
// when multiple languages claim the same extension (e.g. Ecmarkup vs HTML both
// claim .html — HTML should win because it's the canonical language for that
// extension). Known mis-rankings are patched by EXTENSION_OVERRIDES above.
const allLanguages = (Object.values(languages) as LinguistLanguage[]).sort(
(a, b) => (b.extensions?.length ?? 0) - (a.extensions?.length ?? 0)
);
const allLanguages = Object.values(languages) as LinguistLanguage[];
// Collect extensions that linguist-languages assigns to "Markdown" so we can
// exclude them from the code-language map
@@ -42,22 +25,14 @@ const markdownExtensions = new Set(
);
function buildLanguageMaps(
types: string[],
type: string,
excludedExtensions?: Set<string>
): LanguageMaps {
const typeSet = new Set(types);
const extensions = new Map<string, string>();
const filenames = new Map<string, string>();
if (typeSet.has("programming") || typeSet.has("markup")) {
for (const [ext, lang] of Object.entries(EXTENSION_OVERRIDES)) {
if (excludedExtensions?.has(ext.toLowerCase())) continue;
extensions.set(ext, lang);
}
}
for (const lang of allLanguages) {
if (!typeSet.has(lang.type)) continue;
if (lang.type !== type) continue;
const name = lang.name.toLowerCase();
for (const ext of lang.extensions ?? []) {
@@ -82,17 +57,13 @@ function lookupLanguage(name: string, maps: LanguageMaps): string | null {
return (ext && maps.extensions.get(ext)) ?? maps.filenames.get(lower) ?? null;
}
const codeMaps = buildLanguageMaps(
["programming", "markup"],
markdownExtensions
);
const dataMaps = buildLanguageMaps(["data"]);
const codeMaps = buildLanguageMaps("programming", markdownExtensions);
const dataMaps = buildLanguageMaps("data");
/**
* Returns the language name for a given file name, or null if it's not a
* recognised code or markup file (programming + markup types from
* linguist-languages, e.g. Python, HTML, CSS, Vue). Looks up by extension
* first, then by exact filename (e.g. "Dockerfile", "Makefile"). Runs in O(1).
* recognised code file. Looks up by extension first, then by exact filename
* (e.g. "Dockerfile", "Makefile"). Runs in O(1).
*/
export function getCodeLanguage(name: string): string | null {
return lookupLanguage(name, codeMaps);
@@ -115,20 +86,3 @@ export function isMarkdownFile(name: string): boolean {
const ext = name.toLowerCase().match(LANGUAGE_EXT_PATTERN)?.[0];
return !!ext && markdownExtensions.has(ext);
}
const mimeToLanguage = new Map<string, string>();
for (const lang of allLanguages) {
if (lang.codemirrorMimeType && !mimeToLanguage.has(lang.codemirrorMimeType)) {
mimeToLanguage.set(lang.codemirrorMimeType, lang.name.toLowerCase());
}
}
/**
* Returns the language name for a given MIME type using the codemirrorMimeType
* field from linguist-languages (~297 entries). Returns null if unrecognised.
*/
export function getLanguageByMime(mime: string): string | null {
const base = mime.split(";")[0];
if (!base) return null;
return mimeToLanguage.get(base.trim().toLowerCase()) ?? null;
}

View File

@@ -79,7 +79,7 @@ export const USER_STATUS_LABELS: Record<UserStatus, string> = {
[UserStatus.ACTIVE]: "Active",
[UserStatus.INACTIVE]: "Inactive",
[UserStatus.INVITED]: "Invite Pending",
[UserStatus.REQUESTED]: "Request to Join",
[UserStatus.REQUESTED]: "Requested",
};
export const INVALID_ROLE_HOVER_TEXT: Partial<Record<UserRole, string>> = {

View File

@@ -6,8 +6,6 @@ import type { IconProps } from "@opal/types";
export interface ChipProps {
children?: string;
icon?: React.FunctionComponent<IconProps>;
/** Icon rendered after the label (e.g. a warning indicator) */
rightIcon?: React.FunctionComponent<IconProps>;
onRemove?: () => void;
smallLabel?: boolean;
}
@@ -26,7 +24,6 @@ export interface ChipProps {
export default function Chip({
children,
icon: Icon,
rightIcon: RightIcon,
onRemove,
smallLabel = true,
}: ChipProps) {
@@ -38,7 +35,6 @@ export default function Chip({
{children}
</Text>
)}
{RightIcon && <RightIcon size={14} className="text-text-03" />}
{onRemove && (
<Button
onClick={(e) => {

View File

@@ -6,42 +6,10 @@ import { cn } from "@/lib/utils";
// Throttle interval for scroll events (~60fps)
const SCROLL_THROTTLE_MS = 16;
/**
* A scrollable container that shows gradient or shadow indicators when
* content overflows above or below the visible area.
*
* HEIGHT CONSTRAINT REQUIREMENT
*
* This component relies on its inner scroll container having a smaller
* clientHeight than its scrollHeight. For that to happen, the entire
* ancestor chain must constrain height via flex sizing (flex-1 min-h-0),
* NOT via percentage heights (h-full).
*
* height: 100% resolves to "auto" when the containing block's height is
* determined by flex layout (flex-auto, flex-1) rather than an explicit
* height property — this is per the CSS spec. When that happens, the
* container grows to fit its content and scrollHeight === clientHeight,
* making scroll indicators invisible.
*
* Correct pattern: every ancestor up to the nearest fixed-height boundary
* must form an unbroken flex column chain using "flex-1 min-h-0":
*
* fixed-height-ancestor (e.g. h-[500px])
* flex flex-col flex-1 min-h-0 <-- use flex-1, NOT h-full
* ScrollIndicatorDiv
* ...tall content...
*
* Common mistakes:
* - Using h-full instead of flex-1 min-h-0 anywhere in the chain.
* - Placing this inside a parent with overflow-y: auto (e.g. Modal.Body),
* which becomes the scroll container instead of this component's inner div.
*/
export interface ScrollIndicatorDivProps
extends React.HTMLAttributes<HTMLDivElement> {
// Mask/Shadow options
disableIndicators?: boolean;
disableTopIndicator?: boolean;
disableBottomIndicator?: boolean;
backgroundColor?: string;
indicatorHeight?: string;
@@ -54,8 +22,6 @@ export interface ScrollIndicatorDivProps
export default function ScrollIndicatorDiv({
disableIndicators = false,
disableTopIndicator = false,
disableBottomIndicator = false,
backgroundColor = "var(--background-tint-02)",
indicatorHeight = "3rem",
variant = "gradient",
@@ -111,19 +77,13 @@ export default function ScrollIndicatorDiv({
// Update on scroll (throttled)
container.addEventListener("scroll", handleScroll, { passive: true });
// Update when the container itself resizes
// Update on resize (in case content changes)
const resizeObserver = new ResizeObserver(updateScrollIndicators);
resizeObserver.observe(container);
// Update when descendants change (e.g. syntax highlighting mutates the
// DOM after initial render, which changes scrollHeight without firing
// resize or scroll events on the container).
const mutationObserver = new MutationObserver(handleScroll);
return () => {
container.removeEventListener("scroll", handleScroll);
resizeObserver.disconnect();
mutationObserver.disconnect();
if (throttleTimeoutRef.current) {
clearTimeout(throttleTimeoutRef.current);
}
@@ -160,7 +120,7 @@ export default function ScrollIndicatorDiv({
return (
<div className="relative flex-1 min-h-0 overflow-y-hidden flex flex-col w-full">
{/* Top indicator */}
{!disableIndicators && !disableTopIndicator && showTopIndicator && (
{!disableIndicators && showTopIndicator && (
<div
className="absolute top-0 left-0 right-0 z-[20] pointer-events-none transition-opacity duration-200"
style={getIndicatorStyle("top")}
@@ -181,7 +141,7 @@ export default function ScrollIndicatorDiv({
</div>
{/* Bottom indicator */}
{!disableIndicators && !disableBottomIndicator && showBottomIndicator && (
{!disableIndicators && showBottomIndicator && (
<div
className="absolute bottom-0 left-0 right-0 z-[20] pointer-events-none transition-opacity duration-200"
style={getIndicatorStyle("bottom")}

View File

@@ -9,14 +9,11 @@ import {
Variants,
wrapperClasses,
} from "@/refresh-components/inputs/styles";
import { SvgAlertTriangle } from "@opal/icons";
import type { IconProps } from "@opal/types";
export interface ChipItem {
id: string;
label: string;
/** When true the chip shows a warning icon */
error?: boolean;
}
export interface InputChipFieldProps {
@@ -32,8 +29,6 @@ export interface InputChipFieldProps {
variant?: Variants;
icon?: React.FunctionComponent<IconProps>;
className?: string;
/** "inline" renders chips and input in one row; "stacked" puts chips above the input */
layout?: "inline" | "stacked";
}
/**
@@ -66,7 +61,6 @@ function InputChipField({
variant = "primary",
icon: Icon,
className,
layout = "inline",
}: InputChipFieldProps) {
const inputRef = React.useRef<HTMLInputElement>(null);
@@ -91,32 +85,25 @@ function InputChipField({
}
}
const chipElements =
chips.length > 0
? chips.map((chip) => (
<Chip
key={chip.id}
onRemove={disabled ? undefined : () => onRemoveChip(chip.id)}
rightIcon={
chip.error
? (props) => (
<SvgAlertTriangle
{...props}
className="text-status-warning-text"
/>
)
: undefined
}
smallLabel={layout === "stacked"}
>
{chip.label}
</Chip>
))
: null;
const inputElement = (
<>
return (
<div
className={cn(
"flex flex-row items-center flex-wrap gap-1 p-1.5 rounded-08 cursor-text w-full",
wrapperClasses[variant],
className
)}
onClick={() => inputRef.current?.focus()}
>
{Icon && <Icon size={16} className="text-text-04 shrink-0" />}
{chips.map((chip) => (
<Chip
key={chip.id}
onRemove={disabled ? undefined : () => onRemoveChip(chip.id)}
smallLabel={false}
>
{chip.label}
</Chip>
))}
<input
ref={inputRef}
type="text"
@@ -131,36 +118,6 @@ function InputChipField({
textClasses[variant]
)}
/>
</>
);
return (
<div
className={cn(
"flex p-1.5 rounded-08 cursor-text w-full",
layout === "stacked"
? "flex-col gap-1"
: "flex-row flex-wrap items-center gap-1",
wrapperClasses[variant],
className
)}
onClick={() => inputRef.current?.focus()}
>
{layout === "stacked" ? (
<>
{chipElements && (
<div className="flex flex-row items-center flex-wrap gap-1">
{chipElements}
</div>
)}
<div className="flex flex-row items-center gap-1">{inputElement}</div>
</>
) : (
<>
{chipElements}
{inputElement}
</>
)}
</div>
);
}

View File

@@ -133,7 +133,6 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
height,
headerBackground,
serverSide,
emptyState,
} = props;
const effectivePageSize = pageSize ?? (footer ? 10 : data.length);
@@ -274,7 +273,6 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setPage}
leftExtra={footerConfig.leftExtra}
/>
);
}
@@ -303,25 +301,7 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
: undefined),
}}
>
<Table
width={
Object.keys(columnWidths).length > 0
? Object.values(columnWidths).reduce((sum, w) => sum + w, 0)
: undefined
}
>
<colgroup>
{table.getAllLeafColumns().map((col) => (
<col
key={col.id}
style={
columnWidths[col.id] != null
? { width: columnWidths[col.id] }
: undefined
}
/>
))}
</colgroup>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
@@ -448,13 +428,6 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
: undefined
}
>
{emptyState && table.getRowModel().rows.length === 0 && (
<tr>
<td colSpan={table.getVisibleLeafColumns().length}>
{emptyState}
</td>
</tr>
)}
{table.getRowModel().rows.map((row) => {
const rowId = hasDraggable ? getRowId(row.original) : undefined;

View File

@@ -61,8 +61,6 @@ interface FooterSummaryModeProps {
totalPages: number;
/** Called when the user navigates to a different page. */
onPageChange: (page: number) => void;
/** Optional extra element rendered after the summary text (e.g. a download icon). */
leftExtra?: React.ReactNode;
/** Controls overall footer sizing. `"regular"` (default) or `"small"`. */
size?: TableSize;
className?: string;
@@ -117,15 +115,12 @@ export default function Footer(props: FooterProps) {
isSmall={isSmall}
/>
) : (
<>
<SummaryLeft
rangeStart={props.rangeStart}
rangeEnd={props.rangeEnd}
totalItems={props.totalItems}
isSmall={isSmall}
/>
{props.leftExtra}
</>
<SummaryLeft
rangeStart={props.rangeStart}
rangeEnd={props.rangeEnd}
totalItems={props.totalItems}
isSmall={isSmall}
/>
)}
</div>

View File

@@ -21,13 +21,13 @@ export default function TableCell({
const resolvedSize = size ?? contextSize;
return (
<td
className="tbl-cell overflow-hidden"
className="tbl-cell"
data-size={resolvedSize}
style={width != null ? { width } : undefined}
{...props}
>
<div
className={cn("tbl-cell-inner", "flex items-center overflow-hidden")}
className={cn("tbl-cell-inner", "flex items-center")}
data-size={resolvedSize}
>
{children}

View File

@@ -141,8 +141,6 @@ export interface DataTableFooterSelection {
export interface DataTableFooterSummary {
mode: "summary";
/** Optional extra element rendered after the summary text (e.g. a download icon). */
leftExtra?: ReactNode;
}
export type DataTableFooterConfig =
@@ -192,6 +190,4 @@ export interface DataTableProps<TData> {
* - Fires separate callbacks for sorting, pagination, and search changes
*/
serverSide?: ServerSideConfig;
/** Content to render inside the table body when there are no rows. */
emptyState?: React.ReactNode;
}

View File

@@ -12,7 +12,6 @@ import type { StatusFilter } from "./UsersPage/interfaces";
import UsersSummary from "./UsersPage/UsersSummary";
import UsersTable from "./UsersPage/UsersTable";
import InviteUsersModal from "./UsersPage/InviteUsersModal";
// ---------------------------------------------------------------------------
// Users page content
@@ -64,24 +63,19 @@ function UsersContent() {
// ---------------------------------------------------------------------------
export default function UsersPage() {
const [inviteOpen, setInviteOpen] = useState(false);
return (
<SettingsLayouts.Root width="lg">
<SettingsLayouts.Header
title="Users & Requests"
icon={SvgUser}
rightChildren={
<Button icon={SvgUserPlus} onClick={() => setInviteOpen(true)}>
Invite Users
</Button>
// TODO (ENG-3806): Wire up invite modal
<Button icon={SvgUserPlus}>Invite Users</Button>
}
/>
<SettingsLayouts.Body>
<UsersContent />
</SettingsLayouts.Body>
<InviteUsersModal open={inviteOpen} onOpenChange={setInviteOpen} />
</SettingsLayouts.Root>
);
}

View File

@@ -1,332 +0,0 @@
"use client";
import { useState, useMemo, useRef, useCallback } from "react";
import { Button } from "@opal/components";
import { SvgUsers, SvgUser, SvgLogOut, SvgCheck } from "@opal/icons";
import { Disabled } from "@opal/core";
import { ContentAction } from "@opal/layouts";
import Modal from "@/refresh-components/Modal";
import Text from "@/refresh-components/texts/Text";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import LineItem from "@/refresh-components/buttons/LineItem";
import Separator from "@/refresh-components/Separator";
import ShadowDiv from "@/refresh-components/ShadowDiv";
import { Section } from "@/layouts/general-layouts";
import { toast } from "@/hooks/useToast";
import { UserRole, USER_ROLE_LABELS } from "@/lib/types";
import useGroups from "@/hooks/useGroups";
import { addUserToGroup, removeUserFromGroup, setUserRole } from "./svc";
import type { UserRow } from "./interfaces";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ASSIGNABLE_ROLES: UserRole[] = [
UserRole.ADMIN,
UserRole.GLOBAL_CURATOR,
UserRole.BASIC,
];
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface EditGroupsModalProps {
user: UserRow & { id: string };
onClose: () => void;
onMutate: () => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function EditGroupsModal({
user,
onClose,
onMutate,
}: EditGroupsModalProps) {
const { data: allGroups, isLoading: groupsLoading } = useGroups();
const [searchTerm, setSearchTerm] = useState("");
const [dropdownOpen, setDropdownOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const closeDropdown = useCallback(() => {
// Delay to allow click events on dropdown items to fire before closing
setTimeout(() => {
if (!containerRef.current?.contains(document.activeElement)) {
setDropdownOpen(false);
}
}, 0);
}, []);
const [selectedRole, setSelectedRole] = useState<UserRole | "">(
user.role ?? ""
);
const initialMemberGroupIds = useMemo(
() => new Set(user.groups.map((g) => g.id)),
[user.groups]
);
const [memberGroupIds, setMemberGroupIds] = useState<Set<number>>(
() => new Set(initialMemberGroupIds)
);
// Dropdown shows all groups filtered by search term
const dropdownGroups = useMemo(() => {
if (!allGroups) return [];
if (searchTerm.length === 0) return allGroups;
const lower = searchTerm.toLowerCase();
return allGroups.filter((g) => g.name.toLowerCase().includes(lower));
}, [allGroups, searchTerm]);
// Joined groups shown in the modal body
const joinedGroups = useMemo(() => {
if (!allGroups) return [];
return allGroups.filter((g) => memberGroupIds.has(g.id));
}, [allGroups, memberGroupIds]);
const hasGroupChanges = useMemo(() => {
if (memberGroupIds.size !== initialMemberGroupIds.size) return true;
return Array.from(memberGroupIds).some(
(id) => !initialMemberGroupIds.has(id)
);
}, [memberGroupIds, initialMemberGroupIds]);
const hasRoleChange =
user.role !== null && selectedRole !== "" && selectedRole !== user.role;
const hasChanges = hasGroupChanges || hasRoleChange;
const toggleGroup = (groupId: number) => {
setMemberGroupIds((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
};
const handleSave = async () => {
setIsSubmitting(true);
try {
const toAdd = Array.from(memberGroupIds).filter(
(id) => !initialMemberGroupIds.has(id)
);
const toRemove = Array.from(initialMemberGroupIds).filter(
(id) => !memberGroupIds.has(id)
);
if (user.id) {
for (const groupId of toAdd) {
await addUserToGroup(groupId, user.id);
}
for (const groupId of toRemove) {
const group = allGroups?.find((g) => g.id === groupId);
if (group) {
const currentUserIds = group.users.map((u) => u.id);
const ccPairIds = group.cc_pairs.map((cc) => cc.id);
await removeUserFromGroup(
groupId,
currentUserIds,
user.id,
ccPairIds
);
}
}
}
if (
user.role !== null &&
selectedRole !== "" &&
selectedRole !== user.role
) {
await setUserRole(user.email, selectedRole);
}
onMutate();
toast.success("User updated");
onClose();
} catch (err) {
onMutate(); // refresh to show partially-applied state
toast.error(err instanceof Error ? err.message : "An error occurred");
} finally {
setIsSubmitting(false);
}
};
const displayName = user.personal_name ?? user.email;
return (
<Modal open onOpenChange={(isOpen) => !isOpen && onClose()}>
<Modal.Content width="sm">
<Modal.Header
icon={SvgUsers}
title="Edit User's Groups & Roles"
description={
user.personal_name
? `${user.personal_name} (${user.email})`
: user.email
}
onClose={onClose}
/>
<Modal.Body twoTone>
<Section
gap={1}
height="auto"
alignItems="stretch"
justifyContent="start"
>
{/* Subsection: white card behind search + groups */}
<div className="relative">
<div className="absolute -inset-2 bg-background-neutral-00 rounded-12" />
<Section
gap={0.5}
height="auto"
alignItems="stretch"
justifyContent="start"
>
<div ref={containerRef} className="relative">
<InputTypeIn
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
if (!dropdownOpen) setDropdownOpen(true);
}}
onFocus={() => setDropdownOpen(true)}
onBlur={closeDropdown}
placeholder="Search groups to join..."
leftSearchIcon
/>
{dropdownOpen && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-background-neutral-00 border border-border-02 rounded-12 shadow-md p-1">
{groupsLoading ? (
<Text as="p" text03 secondaryBody className="px-3 py-2">
Loading groups...
</Text>
) : dropdownGroups.length === 0 ? (
<Text as="p" text03 secondaryBody className="px-3 py-2">
No groups found
</Text>
) : (
<ShadowDiv className="max-h-[200px] flex flex-col gap-1">
{dropdownGroups.map((group) => {
const isMember = memberGroupIds.has(group.id);
return (
<LineItem
key={group.id}
icon={isMember ? SvgCheck : SvgUsers}
description={`${group.users.length} ${
group.users.length === 1 ? "user" : "users"
}`}
selected={isMember}
emphasized={isMember}
onMouseDown={(e: React.MouseEvent) =>
e.preventDefault()
}
onClick={() => toggleGroup(group.id)}
>
{group.name}
</LineItem>
);
})}
</ShadowDiv>
)}
</div>
)}
</div>
{joinedGroups.length === 0 ? (
<LineItem
icon={SvgUsers}
description={`${displayName} is not in any groups.`}
muted
>
No groups joined
</LineItem>
) : (
<ShadowDiv className="flex flex-col gap-1 max-h-[200px]">
{joinedGroups.map((group) => (
<div
key={group.id}
className="bg-background-tint-01 rounded-08"
>
<LineItem
icon={SvgUsers}
description={`${group.users.length} ${
group.users.length === 1 ? "user" : "users"
}`}
rightChildren={
<SvgLogOut className="w-4 h-4 text-text-03" />
}
onClick={() => toggleGroup(group.id)}
>
{group.name}
</LineItem>
</div>
))}
</ShadowDiv>
)}
</Section>
</div>
{user.role && (
<>
<Separator noPadding />
<ContentAction
title="User Role"
description="This controls their general permissions."
sizePreset="main-ui"
variant="section"
paddingVariant="fit"
rightChildren={
<InputSelect
value={selectedRole}
onValueChange={(v) => setSelectedRole(v as UserRole)}
>
<InputSelect.Trigger />
<InputSelect.Content>
{user.role && !ASSIGNABLE_ROLES.includes(user.role) && (
<InputSelect.Item
key={user.role}
value={user.role}
icon={SvgUser}
>
{USER_ROLE_LABELS[user.role]}
</InputSelect.Item>
)}
{ASSIGNABLE_ROLES.map((role) => (
<InputSelect.Item
key={role}
value={role}
icon={SvgUser}
>
{USER_ROLE_LABELS[role]}
</InputSelect.Item>
))}
</InputSelect.Content>
</InputSelect>
}
/>
</>
)}
</Section>
</Modal.Body>
<Modal.Footer>
<Button prominence="secondary" onClick={onClose}>
Cancel
</Button>
<Disabled disabled={isSubmitting || !hasChanges}>
<Button onClick={handleSave}>Save Changes</Button>
</Disabled>
</Modal.Footer>
</Modal.Content>
</Modal>
);
}

View File

@@ -1,195 +0,0 @@
"use client";
import {
useState,
useRef,
useLayoutEffect,
useCallback,
useEffect,
} from "react";
import { SvgEdit } from "@opal/icons";
import { Tag } from "@opal/components";
import IconButton from "@/refresh-components/buttons/IconButton";
import Text from "@/refresh-components/texts/Text";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
import EditGroupsModal from "./EditGroupsModal";
import type { UserRow, UserGroupInfo } from "./interfaces";
interface GroupsCellProps {
groups: UserGroupInfo[];
user: UserRow;
onMutate: () => void;
}
/**
* Measures how many Tag pills fit in the container, accounting for a "+N"
* overflow counter when not all tags are visible. Uses a two-phase render:
* first renders all tags (clipped by overflow:hidden) for measurement, then
* re-renders with only the visible subset + "+N".
*
* Hovering the cell shows a tooltip with ALL groups. Clicking opens the
* edit groups modal.
*/
export default function GroupsCell({
groups,
user,
onMutate,
}: GroupsCellProps) {
const [showModal, setShowModal] = useState(false);
const [visibleCount, setVisibleCount] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const computeVisibleCount = useCallback(() => {
const container = containerRef.current;
if (!container || groups.length <= 1) {
setVisibleCount(groups.length);
return;
}
const tags = container.querySelectorAll<HTMLElement>("[data-group-tag]");
if (tags.length === 0) return;
const containerWidth = container.clientWidth;
const gap = 4; // gap-1
const counterWidth = 32; // "+N" Tag approximate width
let used = 0;
let count = 0;
for (let i = 0; i < tags.length; i++) {
const tagWidth = tags[i]!.offsetWidth;
const gapBefore = count > 0 ? gap : 0;
const hasMore = i < tags.length - 1;
const reserve = hasMore ? gap + counterWidth : 0;
if (used + gapBefore + tagWidth + reserve <= containerWidth) {
used += gapBefore + tagWidth;
count++;
} else {
break;
}
}
setVisibleCount(Math.max(1, count));
}, [groups]);
// Reset to measurement phase when groups change
useLayoutEffect(() => {
setVisibleCount(null);
}, [groups]);
// Measure after the "show all" render
useLayoutEffect(() => {
if (visibleCount !== null) return;
computeVisibleCount();
}, [visibleCount, computeVisibleCount]);
// Re-measure when the container width changes (e.g. window resize).
// Track width so height-only changes (from the measurement cycle toggling
// visible tags) don't cause an infinite render loop.
const lastWidthRef = useRef(0);
useEffect(() => {
const node = containerRef.current;
if (!node) return;
const observer = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width ?? 0;
if (Math.abs(width - lastWidthRef.current) < 1) return;
lastWidthRef.current = width;
setVisibleCount(null);
});
observer.observe(node);
return () => observer.disconnect();
}, [groups]);
const isMeasuring = visibleCount === null;
const effectiveVisible = visibleCount ?? groups.length;
const overflowCount = groups.length - effectiveVisible;
const hasOverflow = !isMeasuring && overflowCount > 0;
const allGroupsTooltip = (
<div className="flex flex-wrap gap-1 max-w-[14rem]">
{groups.map((g) => (
<div key={g.id} className="max-w-[10rem]">
<Tag title={g.name} size="md" />
</div>
))}
</div>
);
const tagsContent = (
<>
{(isMeasuring ? groups : groups.slice(0, effectiveVisible)).map((g) => (
<div key={g.id} data-group-tag className="flex-shrink-0">
<Tag title={g.name} size="md" />
</div>
))}
{hasOverflow && (
<div className="flex-shrink-0">
<Tag title={`+${overflowCount}`} size="md" />
</div>
)}
</>
);
return (
<>
<div
className={`group/groups relative flex items-center w-full min-w-0 ${
user.id ? "cursor-pointer" : ""
}`}
onClick={user.id ? () => setShowModal(true) : undefined}
>
{groups.length === 0 ? (
<div
ref={containerRef}
className="flex items-center gap-1 overflow-hidden flex-nowrap min-w-0 pr-7"
>
<Text as="span" secondaryBody text03>
</Text>
</div>
) : (
<SimpleTooltip
side="bottom"
align="start"
tooltip={allGroupsTooltip}
disabled={!hasOverflow}
className="bg-background-neutral-01 shadow-sm"
delayDuration={200}
>
<div
ref={containerRef}
className="flex items-center gap-1 overflow-hidden flex-nowrap min-w-0 pr-7"
>
{tagsContent}
</div>
</SimpleTooltip>
)}
{user.id && (
<IconButton
tertiary
icon={SvgEdit}
tooltip="Edit"
toolTipPosition="left"
tooltipSize="sm"
className="absolute right-0 opacity-0 group-hover/groups:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
setShowModal(true);
}}
/>
)}
</div>
{showModal && user.id != null && (
<EditGroupsModal
user={{ ...user, id: user.id }}
onClose={() => setShowModal(false)}
onMutate={onMutate}
/>
)}
</>
);
}

View File

@@ -1,178 +0,0 @@
"use client";
import { useState, useCallback } from "react";
import { Button } from "@opal/components";
import { SvgUsers } from "@opal/icons";
import { Disabled } from "@opal/core";
import Modal, { BasicModalFooter } from "@/refresh-components/Modal";
import InputChipField from "@/refresh-components/inputs/InputChipField";
import type { ChipItem } from "@/refresh-components/inputs/InputChipField";
import { toast } from "@/hooks/useToast";
import { inviteUsers } from "./svc";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface InviteUsersModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function InviteUsersModal({
open,
onOpenChange,
}: InviteUsersModalProps) {
const [chips, setChips] = useState<ChipItem[]>([]);
const [inputValue, setInputValue] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
/** Parse a comma-separated string into de-duped ChipItems */
function parseEmails(value: string, existing: ChipItem[]): ChipItem[] {
const entries = value
.split(",")
.map((e) => e.trim().toLowerCase())
.filter(Boolean);
const newChips: ChipItem[] = [];
for (const email of entries) {
const alreadyAdded =
existing.some((c) => c.label === email) ||
newChips.some((c) => c.label === email);
if (!alreadyAdded) {
newChips.push({
id: email,
label: email,
error: !EMAIL_REGEX.test(email),
});
}
}
return newChips;
}
function addEmail(value: string) {
const newChips = parseEmails(value, chips);
if (newChips.length > 0) {
setChips((prev) => [...prev, ...newChips]);
}
setInputValue("");
}
function removeChip(id: string) {
setChips((prev) => prev.filter((c) => c.id !== id));
}
const handleClose = useCallback(() => {
onOpenChange(false);
// Reset state after close animation
setTimeout(() => {
setChips([]);
setInputValue("");
setIsSubmitting(false);
}, 200);
}, [onOpenChange]);
/** Intercept backdrop/ESC closes so state is always reset */
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) {
if (!isSubmitting) handleClose();
} else {
onOpenChange(next);
}
},
[handleClose, isSubmitting, onOpenChange]
);
async function handleInvite() {
// Flush any pending text in the input into chips synchronously
const pending = inputValue.trim();
const allChips = pending
? [...chips, ...parseEmails(pending, chips)]
: chips;
if (pending) {
setChips(allChips);
setInputValue("");
}
const validEmails = allChips.filter((c) => !c.error).map((c) => c.label);
if (validEmails.length === 0) {
toast.error("Please add at least one valid email address");
return;
}
setIsSubmitting(true);
try {
await inviteUsers(validEmails);
toast.success(
`Invited ${validEmails.length} user${validEmails.length > 1 ? "s" : ""}`
);
handleClose();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to invite users"
);
} finally {
setIsSubmitting(false);
}
}
return (
<Modal open={open} onOpenChange={handleOpenChange}>
<Modal.Content width="sm" height="fit">
<Modal.Header
icon={SvgUsers}
title="Invite Users"
onClose={isSubmitting ? undefined : handleClose}
/>
<Modal.Body>
<InputChipField
chips={chips}
onRemoveChip={removeChip}
onAdd={addEmail}
value={inputValue}
onChange={setInputValue}
placeholder="Add emails to invite, comma separated"
layout="stacked"
/>
</Modal.Body>
<Modal.Footer>
<BasicModalFooter
cancel={
<Disabled disabled={isSubmitting}>
<Button prominence="tertiary" onClick={handleClose}>
Cancel
</Button>
</Disabled>
}
submit={
<Disabled
disabled={
isSubmitting ||
chips.length === 0 ||
chips.every((c) => c.error)
}
>
<Button onClick={handleInvite}>Invite</Button>
</Disabled>
}
/>
</Modal.Footer>
</Modal.Content>
</Modal>
);
}

View File

@@ -1,20 +1,14 @@
"use client";
import { useState } from "react";
import {
SvgCheck,
SvgSlack,
SvgUser,
SvgUserManage,
SvgUsers,
} from "@opal/icons";
import { SvgCheck, SvgSlack, SvgUser, SvgUsers } from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
import FilterButton from "@/refresh-components/buttons/FilterButton";
import Popover from "@/refresh-components/Popover";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import LineItem from "@/refresh-components/buttons/LineItem";
import Text from "@/refresh-components/texts/Text";
import ShadowDiv from "@/refresh-components/ShadowDiv";
import Separator from "@/refresh-components/Separator";
import {
UserRole,
UserStatus,
@@ -24,20 +18,29 @@ import {
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
import type { GroupOption, StatusFilter, StatusCountMap } from "./interfaces";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface UserFiltersProps {
selectedRoles: UserRole[];
onRolesChange: (roles: UserRole[]) => void;
selectedGroups: number[];
onGroupsChange: (groupIds: number[]) => void;
groups: GroupOption[];
selectedStatuses: StatusFilter;
onStatusesChange: (statuses: StatusFilter) => void;
roleCounts: Record<string, number>;
statusCounts: StatusCountMap;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const VISIBLE_FILTER_ROLES: UserRole[] = [
UserRole.ADMIN,
UserRole.GLOBAL_CURATOR,
UserRole.BASIC,
UserRole.SLACK_USER,
];
const FILTERABLE_ROLES = VISIBLE_FILTER_ROLES.map(
(role) => [role, USER_ROLE_LABELS[role]] as [UserRole, string]
);
const FILTERABLE_ROLES = Object.entries(USER_ROLE_LABELS).filter(
([role]) => role !== UserRole.EXT_PERM_USER
) as [UserRole, string][];
const FILTERABLE_STATUSES = (
Object.entries(USER_STATUS_LABELS) as [UserStatus, string][]
@@ -46,7 +49,6 @@ const FILTERABLE_STATUSES = (
);
const ROLE_ICONS: Partial<Record<UserRole, IconFunctionComponent>> = {
[UserRole.ADMIN]: SvgUserManage,
[UserRole.SLACK_USER]: SvgSlack,
};
@@ -74,18 +76,6 @@ function CountBadge({ count }: { count: number | undefined }) {
// Component
// ---------------------------------------------------------------------------
interface UserFiltersProps {
selectedRoles: UserRole[];
onRolesChange: (roles: UserRole[]) => void;
selectedGroups: number[];
onGroupsChange: (groupIds: number[]) => void;
groups: GroupOption[];
selectedStatuses: StatusFilter;
onStatusesChange: (statuses: StatusFilter) => void;
roleCounts: Record<string, number>;
statusCounts: StatusCountMap;
}
export default function UserFilters({
selectedRoles,
onRolesChange,
@@ -111,22 +101,6 @@ export default function UserFilters({
}
};
const toggleGroup = (groupId: number) => {
if (selectedGroups.includes(groupId)) {
onGroupsChange(selectedGroups.filter((id) => id !== groupId));
} else {
onGroupsChange([...selectedGroups, groupId]);
}
};
const toggleStatus = (status: UserStatus) => {
if (selectedStatuses.includes(status)) {
onStatusesChange(selectedStatuses.filter((s) => s !== status));
} else {
onStatusesChange([...selectedStatuses, status]);
}
};
const roleLabel = hasRoleFilter
? FILTERABLE_ROLES.filter(([role]) => selectedRoles.includes(role))
.map(([, label]) => label)
@@ -135,6 +109,14 @@ export default function UserFilters({
(selectedRoles.length > 2 ? `, +${selectedRoles.length - 2}` : "")
: "All Account Types";
const toggleGroup = (groupId: number) => {
if (selectedGroups.includes(groupId)) {
onGroupsChange(selectedGroups.filter((id) => id !== groupId));
} else {
onGroupsChange([...selectedGroups, groupId]);
}
};
const groupLabel = hasGroupFilter
? groups
.filter((g) => selectedGroups.includes(g.id))
@@ -144,6 +126,14 @@ export default function UserFilters({
(selectedGroups.length > 2 ? `, +${selectedGroups.length - 2}` : "")
: "All Groups";
const toggleStatus = (status: UserStatus) => {
if (selectedStatuses.includes(status)) {
onStatusesChange(selectedStatuses.filter((s) => s !== status));
} else {
onStatusesChange([...selectedStatuses, status]);
}
};
const statusLabel = hasStatusFilter
? FILTERABLE_STATUSES.filter(([status]) =>
selectedStatuses.includes(status)
@@ -176,13 +166,13 @@ export default function UserFilters({
<Popover.Content align="start">
<div className="flex flex-col gap-1 p-1 min-w-[200px]">
<LineItem
icon={!hasRoleFilter ? SvgCheck : SvgUsers}
icon={SvgUsers}
selected={!hasRoleFilter}
emphasized={!hasRoleFilter}
onClick={() => onRolesChange([])}
>
All Account Types
</LineItem>
<Separator noPadding />
{FILTERABLE_ROLES.map(([role, label]) => {
const isSelected = selectedRoles.includes(role);
const roleIcon = ROLE_ICONS[role] ?? SvgUser;
@@ -191,7 +181,6 @@ export default function UserFilters({
key={role}
icon={isSelected ? SvgCheck : roleIcon}
selected={isSelected}
emphasized={isSelected}
onClick={() => toggleRole(role)}
rightChildren={<CountBadge count={roleCounts[role]} />}
>
@@ -222,30 +211,30 @@ export default function UserFilters({
</Popover.Trigger>
<Popover.Content align="start">
<div className="flex flex-col gap-1 p-1 min-w-[200px]">
<InputTypeIn
value={groupSearch}
onChange={(e) => setGroupSearch(e.target.value)}
placeholder="Search groups..."
leftSearchIcon
variant="internal"
/>
<div className="px-1 pt-1">
<InputTypeIn
value={groupSearch}
onChange={(e) => setGroupSearch(e.target.value)}
placeholder="Search groups..."
leftSearchIcon
/>
</div>
<LineItem
icon={!hasGroupFilter ? SvgCheck : SvgUsers}
icon={SvgUsers}
selected={!hasGroupFilter}
emphasized={!hasGroupFilter}
onClick={() => onGroupsChange([])}
>
All Groups
</LineItem>
<ShadowDiv className="flex flex-col gap-1 max-h-[240px]">
<Separator noPadding />
<div className="flex flex-col gap-1 max-h-[240px] overflow-y-auto">
{filteredGroups.map((group) => {
const isSelected = selectedGroups.includes(group.id);
return (
<LineItem
key={group.id}
icon={isSelected ? SvgCheck : SvgUsers}
icon={isSelected ? SvgCheck : undefined}
selected={isSelected}
emphasized={isSelected}
onClick={() => toggleGroup(group.id)}
rightChildren={<CountBadge count={group.memberCount} />}
>
@@ -258,7 +247,7 @@ export default function UserFilters({
No groups found
</Text>
)}
</ShadowDiv>
</div>
</div>
</Popover.Content>
</Popover>
@@ -277,22 +266,21 @@ export default function UserFilters({
<Popover.Content align="start">
<div className="flex flex-col gap-1 p-1 min-w-[200px]">
<LineItem
icon={!hasStatusFilter ? SvgCheck : SvgUser}
icon={!hasStatusFilter ? SvgCheck : undefined}
selected={!hasStatusFilter}
emphasized={!hasStatusFilter}
onClick={() => onStatusesChange([])}
>
All Status
</LineItem>
<Separator noPadding />
{FILTERABLE_STATUSES.map(([status, label]) => {
const isSelected = selectedStatuses.includes(status);
const countKey = STATUS_COUNT_KEY[status];
return (
<LineItem
key={status}
icon={isSelected ? SvgCheck : SvgUser}
icon={isSelected ? SvgCheck : undefined}
selected={isSelected}
emphasized={isSelected}
onClick={() => toggleStatus(status)}
rightChildren={<CountBadge count={statusCounts[countKey]} />}
>

View File

@@ -1,135 +0,0 @@
"use client";
import { useState, useRef } from "react";
import { UserRole, USER_ROLE_LABELS } from "@/lib/types";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { OpenButton } from "@opal/components";
import { Disabled } from "@opal/core";
import {
SvgCheck,
SvgGlobe,
SvgUser,
SvgSlack,
SvgUserManage,
} from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
import Text from "@/refresh-components/texts/Text";
import Popover from "@/refresh-components/Popover";
import LineItem from "@/refresh-components/buttons/LineItem";
import { toast } from "@/hooks/useToast";
import { setUserRole } from "./svc";
import type { UserRow } from "./interfaces";
const ROLE_ICONS: Partial<Record<UserRole, IconFunctionComponent>> = {
[UserRole.ADMIN]: SvgUserManage,
[UserRole.GLOBAL_CURATOR]: SvgGlobe,
[UserRole.SLACK_USER]: SvgSlack,
};
const SELECTABLE_ROLES = [
UserRole.ADMIN,
UserRole.GLOBAL_CURATOR,
UserRole.BASIC,
] as const;
interface UserRoleCellProps {
user: UserRow;
onMutate: () => void;
}
export default function UserRoleCell({ user, onMutate }: UserRoleCellProps) {
const [isUpdating, setIsUpdating] = useState(false);
const [open, setOpen] = useState(false);
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
const isUpdatingRef = useRef(false);
if (!user.role) {
return (
<Text as="span" secondaryBody text03>
</Text>
);
}
if (user.is_scim_synced) {
return (
<div className="flex items-center gap-1.5">
<Text as="span" mainUiBody text03>
{USER_ROLE_LABELS[user.role] ?? user.role}
</Text>
</div>
);
}
const applyRole = async (newRole: UserRole) => {
if (isUpdatingRef.current) return;
isUpdatingRef.current = true;
setIsUpdating(true);
try {
await setUserRole(user.email, newRole);
toast.success("Role updated");
onMutate();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to update role");
onMutate();
} finally {
setIsUpdating(false);
isUpdatingRef.current = false;
}
};
const handleSelect = (role: UserRole) => {
if (role === user.role) {
setOpen(false);
return;
}
setOpen(false);
void applyRole(role);
};
const currentIcon = ROLE_ICONS[user.role] ?? SvgUser;
return (
<div className="[&_button]:rounded-08">
<Disabled disabled={isUpdating}>
<Popover open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<OpenButton
icon={currentIcon}
variant="select-tinted"
width="full"
justifyContent="between"
>
{USER_ROLE_LABELS[user.role]}
</OpenButton>
</Popover.Trigger>
<Popover.Content align="start">
<div className="flex flex-col gap-1 p-1 min-w-[160px]">
{SELECTABLE_ROLES.map((role) => {
if (
role === UserRole.GLOBAL_CURATOR &&
!isPaidEnterpriseFeaturesEnabled
) {
return null;
}
const isSelected = user.role === role;
const icon = ROLE_ICONS[role] ?? SvgUser;
return (
<LineItem
key={role}
icon={isSelected ? SvgCheck : icon}
selected={isSelected}
emphasized={isSelected}
onClick={() => handleSelect(role)}
>
{USER_ROLE_LABELS[role]}
</LineItem>
);
})}
</div>
</Popover.Content>
</Popover>
</Disabled>
</div>
);
}

View File

@@ -2,40 +2,21 @@
import { useState } from "react";
import { Button } from "@opal/components";
import {
SvgMoreHorizontal,
SvgUsers,
SvgXCircle,
SvgTrash,
SvgCheck,
} from "@opal/icons";
import { SvgMoreHorizontal, SvgXCircle, SvgTrash, SvgCheck } from "@opal/icons";
import { Disabled } from "@opal/core";
import Popover from "@/refresh-components/Popover";
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
import Text from "@/refresh-components/texts/Text";
import { UserStatus } from "@/lib/types";
import { toast } from "@/hooks/useToast";
import {
deactivateUser,
activateUser,
deleteUser,
cancelInvite,
approveRequest,
} from "./svc";
import EditGroupsModal from "./EditGroupsModal";
import { deactivateUser, activateUser, deleteUser } from "./svc";
import type { UserRow } from "./interfaces";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ModalType =
| "deactivate"
| "activate"
| "delete"
| "cancelInvite"
| "editGroups"
| null;
type ModalType = "deactivate" | "activate" | "delete" | null;
interface UserRowActionsProps {
user: UserRow;
@@ -71,101 +52,14 @@ export default function UserRowActions({
}
}
const openModal = (type: ModalType) => {
setPopoverOpen(false);
setModal(type);
};
// Status-aware action menus
const actionButtons = (() => {
switch (user.status) {
case UserStatus.INVITED:
return (
<Button
prominence="tertiary"
variant="danger"
icon={SvgXCircle}
onClick={() => openModal("cancelInvite")}
>
Cancel Invite
</Button>
);
case UserStatus.REQUESTED:
return (
<Button
prominence="tertiary"
icon={SvgCheck}
onClick={() => {
setPopoverOpen(false);
handleAction(
() => approveRequest(user.email),
"Request approved"
);
}}
>
Approve
</Button>
);
case UserStatus.ACTIVE:
return (
<>
{user.id && (
<Button
prominence="tertiary"
icon={SvgUsers}
onClick={() => openModal("editGroups")}
>
Groups
</Button>
)}
<Button
prominence="tertiary"
icon={SvgXCircle}
onClick={() => openModal("deactivate")}
>
Deactivate User
</Button>
</>
);
case UserStatus.INACTIVE:
return (
<>
{user.id && (
<Button
prominence="tertiary"
icon={SvgUsers}
onClick={() => openModal("editGroups")}
>
Groups
</Button>
)}
<Button
prominence="tertiary"
icon={SvgCheck}
onClick={() => openModal("activate")}
>
Activate User
</Button>
<Button
prominence="tertiary"
variant="danger"
icon={SvgTrash}
onClick={() => openModal("delete")}
>
Delete User
</Button>
</>
);
default: {
const _exhaustive: never = user.status;
return null;
}
}
})();
// Only show actions for accepted users (active or inactive).
// Invited/requested users have no row actions in this PR.
if (
user.status !== UserStatus.ACTIVE &&
user.status !== UserStatus.INACTIVE
) {
return null;
}
// SCIM-managed users cannot be modified from the UI — changes would be
// overwritten on the next IdP sync.
@@ -180,47 +74,46 @@ export default function UserRowActions({
<Button prominence="tertiary" icon={SvgMoreHorizontal} />
</Popover.Trigger>
<Popover.Content align="end">
<div className="flex flex-col gap-0.5 p-1">{actionButtons}</div>
</Popover.Content>
</Popover>
{modal === "editGroups" && user.id && (
<EditGroupsModal
user={user as UserRow & { id: string }}
onClose={() => setModal(null)}
onMutate={onMutate}
/>
)}
{modal === "cancelInvite" && (
<ConfirmationModalLayout
icon={SvgXCircle}
title="Cancel Invite"
onClose={() => setModal(null)}
submit={
<Disabled disabled={isSubmitting}>
<div className="flex flex-col gap-0.5 p-1">
{user.status === UserStatus.ACTIVE ? (
<Button
variant="danger"
prominence="tertiary"
icon={SvgXCircle}
onClick={() => {
handleAction(
() => cancelInvite(user.email),
"Invite cancelled"
);
setPopoverOpen(false);
setModal("deactivate");
}}
>
Cancel
Deactivate User
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{user.email}
</Text>{" "}
will no longer be able to join Onyx with this invite.
</Text>
</ConfirmationModalLayout>
)}
) : (
<>
<Button
prominence="tertiary"
icon={SvgCheck}
onClick={() => {
setPopoverOpen(false);
setModal("activate");
}}
>
Activate User
</Button>
<Button
prominence="tertiary"
variant="danger"
icon={SvgTrash}
onClick={() => {
setPopoverOpen(false);
setModal("delete");
}}
>
Delete User
</Button>
</>
)}
</div>
</Popover.Content>
</Popover>
{modal === "deactivate" && (
<ConfirmationModalLayout
@@ -248,8 +141,7 @@ export default function UserRowActions({
{user.email}
</Text>{" "}
will immediately lose access to Onyx. Their sessions and agents will
be preserved. Their license seat will be freed. You can reactivate
this account later.
be preserved. You can reactivate this account later.
</Text>
</ConfirmationModalLayout>
)}
@@ -309,7 +201,7 @@ export default function UserRowActions({
{user.email}
</Text>{" "}
will be permanently removed from Onyx. All of their session history
will be deleted. Deletion cannot be undone.
will be deleted. This cannot be undone.
</Text>
</ConfirmationModalLayout>
)}

View File

@@ -1,15 +1,14 @@
import { SvgArrowUpRight, SvgFilterPlus, SvgUserSync } from "@opal/icons";
import { SvgArrowUpRight, SvgFilter, SvgUserSync } from "@opal/icons";
import { ContentAction } from "@opal/layouts";
import { Button } from "@opal/components";
import { Section } from "@/layouts/general-layouts";
import Card from "@/refresh-components/cards/Card";
import IconButton from "@/refresh-components/buttons/IconButton";
import Text from "@/refresh-components/texts/Text";
import Link from "next/link";
import { ADMIN_PATHS } from "@/lib/admin-routes";
// ---------------------------------------------------------------------------
// Stats cell — number + label + hover filter icon
// Stats cell — number + label
// ---------------------------------------------------------------------------
type StatCellProps = {
@@ -35,18 +34,12 @@ function StatCell({ value, label, onFilter }: StatCellProps) {
{label}
</Text>
{onFilter && (
<IconButton
tertiary
icon={SvgFilterPlus}
tooltip="Add Filter"
toolTipPosition="left"
tooltipSize="sm"
className="absolute right-1 top-1 opacity-0 group-hover/stat:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
onFilter();
}}
/>
<div className="absolute right-2 top-2 flex items-center gap-1 opacity-0 group-hover/stat:opacity-100 transition-opacity">
<Text as="span" secondaryBody text03>
Filter
</Text>
<SvgFilter size={16} className="text-text-03" />
</div>
)}
</div>
);

View File

@@ -4,31 +4,47 @@ import { useMemo, useState } from "react";
import DataTable from "@/refresh-components/table/DataTable";
import { createTableColumns } from "@/refresh-components/table/columns";
import { Content } from "@opal/layouts";
import { Button } from "@opal/components";
import { SvgDownload } from "@opal/icons";
import { SvgUser, SvgUsers, SvgSlack } from "@opal/icons";
import SvgNoResult from "@opal/illustrations/no-result";
import { IllustrationContent } from "@opal/layouts";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { UserRole, UserStatus, USER_STATUS_LABELS } from "@/lib/types";
import type { IconFunctionComponent } from "@opal/types";
import {
UserRole,
UserStatus,
USER_ROLE_LABELS,
USER_STATUS_LABELS,
} from "@/lib/types";
import { timeAgo } from "@/lib/time";
import Text from "@/refresh-components/texts/Text";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import { toast } from "@/hooks/useToast";
import useAdminUsers from "@/hooks/useAdminUsers";
import useGroups from "@/hooks/useGroups";
import { downloadUsersCsv } from "./svc";
import UserFilters from "./UserFilters";
import GroupsCell from "./GroupsCell";
import UserRowActions from "./UserRowActions";
import UserRoleCell from "./UserRoleCell";
import type {
UserRow,
UserGroupInfo,
GroupOption,
StatusFilter,
StatusCountMap,
} from "./interfaces";
import { getInitials } from "./utils";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ROLE_ICONS: Record<UserRole, IconFunctionComponent> = {
[UserRole.BASIC]: SvgUser,
[UserRole.ADMIN]: SvgUser,
[UserRole.GLOBAL_CURATOR]: SvgUsers,
[UserRole.CURATOR]: SvgUsers,
[UserRole.LIMITED]: SvgUser,
[UserRole.EXT_PERM_USER]: SvgUser,
[UserRole.SLACK_USER]: SvgSlack,
};
// ---------------------------------------------------------------------------
// Column renderers
// ---------------------------------------------------------------------------
@@ -44,6 +60,56 @@ function renderNameColumn(email: string, row: UserRow) {
);
}
function renderGroupsColumn(groups: UserGroupInfo[]) {
if (!groups.length) {
return (
<Text as="span" secondaryBody text03>
{"\u2014"}
</Text>
);
}
const visible = groups.slice(0, 2);
const overflow = groups.length - visible.length;
return (
<div className="flex items-center gap-1 flex-nowrap overflow-hidden min-w-0">
{visible.map((g) => (
<span
key={g.id}
className="inline-flex items-center flex-shrink-0 rounded-md bg-background-tint-02 px-2 py-0.5 whitespace-nowrap"
>
<Text as="span" secondaryBody text03>
{g.name}
</Text>
</span>
))}
{overflow > 0 && (
<Text as="span" secondaryBody text03>
+{overflow}
</Text>
)}
</div>
);
}
function renderRoleColumn(role: UserRole | null) {
if (!role) {
return (
<Text as="span" secondaryBody text03>
</Text>
);
}
const Icon = ROLE_ICONS[role];
return (
<div className="flex items-center gap-1.5">
{Icon && <Icon size={14} className="text-text-03 shrink-0" />}
<Text as="span" mainUiBody text03>
{USER_ROLE_LABELS[role] ?? role}
</Text>
</div>
);
}
function renderStatusColumn(value: UserStatus, row: UserRow) {
return (
<div className="flex flex-col">
@@ -62,7 +128,7 @@ function renderStatusColumn(value: UserStatus, row: UserRow) {
function renderLastUpdatedColumn(value: string | null) {
return (
<Text as="span" secondaryBody text03>
{value ? timeAgo(value) ?? "\u2014" : "\u2014"}
{timeAgo(value) ?? "\u2014"}
</Text>
);
}
@@ -91,15 +157,13 @@ function buildColumns(onMutate: () => void) {
weight: 24,
minWidth: 200,
enableSorting: false,
cell: (value, row) => (
<GroupsCell groups={value} user={row} onMutate={onMutate} />
),
cell: renderGroupsColumn,
}),
tc.column("role", {
header: "Account Type",
weight: 16,
minWidth: 180,
cell: (_value, row) => <UserRoleCell user={row} onMutate={onMutate} />,
cell: renderRoleColumn,
}),
tc.column("status", {
header: "Status",
@@ -216,40 +280,22 @@ export default function UsersTable({
roleCounts={roleCounts}
statusCounts={statusCounts}
/>
<DataTable
data={filteredUsers}
columns={columns}
getRowId={(row) => row.id ?? row.email}
pageSize={PAGE_SIZE}
searchTerm={searchTerm}
emptyState={
<IllustrationContent
illustration={SvgNoResult}
title="No users found"
description="No users match the current filters."
/>
}
footer={{
mode: "summary",
leftExtra: (
<Button
icon={SvgDownload}
prominence="tertiary"
size="sm"
tooltip="Download CSV"
onClick={() => {
downloadUsersCsv().catch((err) => {
toast.error(
err instanceof Error
? err.message
: "Failed to download CSV"
);
});
}}
/>
),
}}
/>
{filteredUsers.length === 0 ? (
<IllustrationContent
illustration={SvgNoResult}
title="No users found"
description="No users match the current filters."
/>
) : (
<DataTable
data={filteredUsers}
columns={columns}
getRowId={(row) => row.id ?? row.email}
pageSize={PAGE_SIZE}
searchTerm={searchTerm}
footer={{ mode: "summary" }}
/>
)}
</div>
);
}

View File

@@ -1,5 +1,3 @@
import { UserRole } from "@/lib/types";
async function parseErrorDetail(
res: Response,
fallback: string
@@ -44,102 +42,3 @@ export async function deleteUser(email: string): Promise<void> {
throw new Error(await parseErrorDetail(res, "Failed to delete user"));
}
}
export async function setUserRole(
email: string,
newRole: UserRole
): Promise<void> {
const res = await fetch("/api/manage/set-user-role", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_email: email, new_role: newRole }),
});
if (!res.ok) {
throw new Error(await parseErrorDetail(res, "Failed to update user role"));
}
}
export async function addUserToGroup(
groupId: number,
userId: string
): Promise<void> {
const res = await fetch(`/api/manage/admin/user-group/${groupId}/add-users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_ids: [userId] }),
});
if (!res.ok) {
throw new Error(await parseErrorDetail(res, "Failed to add user to group"));
}
}
export async function removeUserFromGroup(
groupId: number,
currentUserIds: string[],
userIdToRemove: string,
ccPairIds: number[]
): Promise<void> {
const res = await fetch(`/api/manage/admin/user-group/${groupId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_ids: currentUserIds.filter((id) => id !== userIdToRemove),
cc_pair_ids: ccPairIds,
}),
});
if (!res.ok) {
throw new Error(
await parseErrorDetail(res, "Failed to remove user from group")
);
}
}
export async function cancelInvite(email: string): Promise<void> {
const res = await fetch("/api/manage/admin/remove-invited-user", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_email: email }),
});
if (!res.ok) {
throw new Error(await parseErrorDetail(res, "Failed to cancel invite"));
}
}
export async function approveRequest(email: string): Promise<void> {
const res = await fetch("/api/tenants/users/invite/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
if (!res.ok) {
throw new Error(await parseErrorDetail(res, "Failed to approve request"));
}
}
export async function inviteUsers(emails: string[]): Promise<void> {
const res = await fetch("/api/manage/admin/users", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ emails }),
});
if (!res.ok) {
throw new Error(await parseErrorDetail(res, "Failed to invite users"));
}
}
export async function downloadUsersCsv(): Promise<void> {
const res = await fetch("/api/manage/users/download");
if (!res.ok) {
throw new Error(
await parseErrorDetail(res, "Failed to download users CSV")
);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
a.download = `onyx_users_${ts}.csv`;
a.click();
URL.revokeObjectURL(url);
}

View File

@@ -7,14 +7,13 @@ import Text from "@/refresh-components/texts/Text";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { cn } from "@/lib/utils";
import { Section } from "@/layouts/general-layouts";
import mime from "mime";
import {
getCodeLanguage,
getDataLanguage,
getLanguageByMime,
} from "@/lib/languages";
import { getCodeLanguage, getDataLanguage } from "@/lib/languages";
import { fetchChatFile } from "@/lib/chat/svc";
import { PreviewContext } from "@/sections/modals/PreviewModal/interfaces";
import {
getMimeLanguage,
resolveMimeType,
} from "@/sections/modals/PreviewModal/mimeUtils";
import { resolveVariant } from "@/sections/modals/PreviewModal/variants";
interface PreviewModalProps {
@@ -42,7 +41,7 @@ export default function PreviewModal({
const language = useMemo(
() =>
getCodeLanguage(presentingDocument.semantic_identifier || "") ||
getLanguageByMime(mimeType) ||
getMimeLanguage(mimeType) ||
getDataLanguage(presentingDocument.semantic_identifier || "") ||
"plaintext",
[mimeType, presentingDocument.semantic_identifier]
@@ -87,10 +86,7 @@ export default function PreviewModal({
const rawContentType =
response.headers.get("Content-Type") || "application/octet-stream";
const resolvedMime =
rawContentType === "application/octet-stream"
? mime.getType(originalFileName) ?? rawContentType
: rawContentType;
const resolvedMime = resolveMimeType(rawContentType, originalFileName);
setMimeType(resolvedMime);
const resolved = resolveVariant(
@@ -170,24 +166,24 @@ export default function PreviewModal({
onClose={onClose}
/>
{/* Body — uses flex-1/min-h-0/overflow-hidden (not Modal.Body)
so that child ScrollIndicatorDivs become the actual scroll
container instead of the body stealing it via overflow-y-auto. */}
<div className="flex flex-col flex-1 min-h-0 overflow-hidden w-full bg-background-tint-01">
{isLoading ? (
<Section>
<SimpleLoader className="h-8 w-8" />
</Section>
) : loadError ? (
<Section padding={1}>
<Text text03 mainUiBody>
{loadError}
</Text>
</Section>
) : (
variant.renderContent(ctx)
)}
</div>
{/* Body + floating footer wrapper */}
<Modal.Body padding={0} gap={0}>
<Section padding={0} gap={0}>
{isLoading ? (
<Section>
<SimpleLoader className="h-8 w-8" />
</Section>
) : loadError ? (
<Section padding={1}>
<Text text03 mainUiBody>
{loadError}
</Text>
</Section>
) : (
variant.renderContent(ctx)
)}
</Section>
</Modal.Body>
{/* Floating footer */}
{!isLoading && !loadError && (
@@ -198,9 +194,8 @@ export default function PreviewModal({
"p-4 pointer-events-none w-full"
)}
style={{
background: `linear-gradient(to top, var(--background-${
variant.codeBackground ? "code-01" : "tint-01"
}) 40%, transparent)`,
background:
"linear-gradient(to top, var(--background-code-01) 40%, transparent)",
}}
>
{/* Left slot */}

View File

@@ -19,8 +19,6 @@ export interface PreviewVariant
matches: (semanticIdentifier: string | null, mimeType: string) => boolean;
/** Whether the fetcher should read the blob as text. */
needsTextContent: boolean;
/** Whether the variant renders on a code-style background (bg-background-code-01). */
codeBackground: boolean;
/** String shown below the title in the modal header. */
headerDescription: (ctx: PreviewContext) => string;
/** Body content. */

View File

@@ -0,0 +1,50 @@
const MIME_LANGUAGE_PREFIXES: Array<[prefix: string, language: string]> = [
["application/json", "json"],
["application/xml", "xml"],
["text/xml", "xml"],
["application/x-yaml", "yaml"],
["application/yaml", "yaml"],
["text/yaml", "yaml"],
["text/x-yaml", "yaml"],
];
const OCTET_STREAM_EXTENSION_TO_MIME: Record<string, string> = {
".md": "text/markdown",
".markdown": "text/markdown",
".txt": "text/plain",
".log": "text/plain",
".conf": "text/plain",
".sql": "text/plain",
".csv": "text/csv",
".tsv": "text/tab-separated-values",
".json": "application/json",
".xml": "application/xml",
".yml": "application/x-yaml",
".yaml": "application/x-yaml",
};
export function getMimeLanguage(mimeType: string): string | null {
return (
MIME_LANGUAGE_PREFIXES.find(([prefix]) =>
mimeType.startsWith(prefix)
)?.[1] ?? null
);
}
export function resolveMimeType(mimeType: string, fileName: string): string {
if (mimeType !== "application/octet-stream") {
return mimeType;
}
const lowerFileName = fileName.toLowerCase();
for (const [extension, resolvedMime] of Object.entries(
OCTET_STREAM_EXTENSION_TO_MIME
)) {
if (lowerFileName.endsWith(extension)) {
return resolvedMime;
}
}
return mimeType;
}

View File

@@ -1,37 +1,22 @@
"use client";
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import ScrollIndicatorDiv from "@/refresh-components/ScrollIndicatorDiv";
import { cn } from "@/lib/utils";
import "@/app/app/message/custom-code-styles.css";
interface CodePreviewProps {
content: string;
language?: string | null;
normalize?: boolean;
}
export function CodePreview({
content,
language,
normalize,
}: CodePreviewProps) {
// Wrap raw content in a fenced code block for syntax highlighting. Uses ~~~
// instead of ``` to avoid conflicts with backticks in the content. Any literal
// ~~~ sequences in the content are escaped so they don't accidentally close the fence.
const markdownContent = normalize
? `~~~${language || ""}\n${content.replace(/~~~/g, "\\~\\~\\~")}\n~~~`
: content;
export function CodePreview({ content, language }: CodePreviewProps) {
const normalizedContent = content.replace(/~~~/g, "\\~\\~\\~");
const fenceHeader = language ? `~~~${language}` : "~~~";
return (
<ScrollIndicatorDiv
className={cn("p-4", normalize && "bg-background-code-01")}
backgroundColor={normalize ? "var(--background-code-01)" : undefined}
variant="shadow"
bottomSpacing="2rem"
disableBottomIndicator
>
<MinimalMarkdown content={markdownContent} showHeader={false} />
</ScrollIndicatorDiv>
<MinimalMarkdown
content={`${fenceHeader}\n${normalizedContent}\n\n~~~`}
className="w-full h-full"
showHeader={false}
/>
);
}

View File

@@ -13,7 +13,6 @@ export const codeVariant: PreviewVariant = {
width: "md",
height: "lg",
needsTextContent: true,
codeBackground: true,
headerDescription: (ctx) =>
ctx.fileContent
@@ -23,7 +22,7 @@ export const codeVariant: PreviewVariant = {
: "",
renderContent: (ctx) => (
<CodePreview normalize content={ctx.fileContent} language={ctx.language} />
<CodePreview content={ctx.fileContent} language={ctx.language} />
),
renderFooterLeft: (ctx) => (

View File

@@ -34,7 +34,6 @@ export const csvVariant: PreviewVariant = {
width: "lg",
height: "full",
needsTextContent: true,
codeBackground: false,
headerDescription: (ctx) => {
if (!ctx.fileContent) return "";
const { rows } = parseCsv(ctx.fileContent);

View File

@@ -1,7 +1,8 @@
import Text from "@/refresh-components/texts/Text";
import { Section } from "@/layouts/general-layouts";
import { getDataLanguage, getLanguageByMime } from "@/lib/languages";
import { getDataLanguage } from "@/lib/languages";
import { PreviewVariant } from "@/sections/modals/PreviewModal/interfaces";
import { getMimeLanguage } from "@/sections/modals/PreviewModal/mimeUtils";
import { CodePreview } from "@/sections/modals/PreviewModal/variants/CodePreview";
import {
CopyButton,
@@ -21,11 +22,10 @@ function formatContent(language: string, content: string): string {
export const dataVariant: PreviewVariant = {
matches: (name, mime) =>
!!getDataLanguage(name || "") || !!getLanguageByMime(mime),
!!getDataLanguage(name || "") || !!getMimeLanguage(mime),
width: "md",
height: "lg",
needsTextContent: true,
codeBackground: true,
headerDescription: (ctx) =>
ctx.fileContent
@@ -36,9 +36,7 @@ export const dataVariant: PreviewVariant = {
renderContent: (ctx) => {
const formatted = formatContent(ctx.language, ctx.fileContent);
return (
<CodePreview normalize content={formatted} language={ctx.language} />
);
return <CodePreview content={formatted} language={ctx.language} />;
},
renderFooterLeft: (ctx) => (

View File

@@ -130,7 +130,6 @@ export const docxVariant: PreviewVariant = {
width: "lg",
height: "full",
needsTextContent: false,
codeBackground: false,
headerDescription: () => {
if (lastDocxResult) {
const count = lastDocxResult.wordCount;

View File

@@ -11,7 +11,6 @@ export const imageVariant: PreviewVariant = {
width: "lg",
height: "full",
needsTextContent: false,
codeBackground: false,
headerDescription: () => "",
renderContent: (ctx) => (

View File

@@ -15,10 +15,10 @@ const PREVIEW_VARIANTS: PreviewVariant[] = [
imageVariant,
pdfVariant,
csvVariant,
dataVariant,
textVariant,
markdownVariant,
docxVariant,
textVariant,
dataVariant,
];
export function resolveVariant(

View File

@@ -1,7 +1,8 @@
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import ScrollIndicatorDiv from "@/refresh-components/ScrollIndicatorDiv";
import { Section } from "@/layouts/general-layouts";
import { isMarkdownFile } from "@/lib/languages";
import { PreviewVariant } from "@/sections/modals/PreviewModal/interfaces";
import { CodePreview } from "@/sections/modals/PreviewModal/variants/CodePreview";
import {
CopyButton,
DownloadButton,
@@ -22,11 +23,15 @@ export const markdownVariant: PreviewVariant = {
width: "lg",
height: "full",
needsTextContent: true,
codeBackground: false,
headerDescription: () => "",
renderContent: (ctx) => (
<CodePreview content={ctx.fileContent} language={ctx.language} />
<ScrollIndicatorDiv className="flex-1 min-h-0 p-4" variant="shadow">
<MinimalMarkdown
content={ctx.fileContent}
className="w-full pb-4 text-lg break-words"
/>
</ScrollIndicatorDiv>
),
renderFooterLeft: () => null,

View File

@@ -7,7 +7,6 @@ export const pdfVariant: PreviewVariant = {
width: "lg",
height: "full",
needsTextContent: false,
codeBackground: false,
headerDescription: () => "",
renderContent: (ctx) => (

View File

@@ -28,7 +28,6 @@ export const textVariant: PreviewVariant = {
width: "md",
height: "lg",
needsTextContent: true,
codeBackground: true,
headerDescription: (ctx) =>
ctx.fileContent
? `${ctx.lineCount} ${ctx.lineCount === 1 ? "line" : "lines"} · ${
@@ -37,7 +36,7 @@ export const textVariant: PreviewVariant = {
: "",
renderContent: (ctx) => (
<CodePreview normalize content={ctx.fileContent} language={ctx.language} />
<CodePreview content={ctx.fileContent} language={ctx.language} />
),
renderFooterLeft: (ctx) => (

View File

@@ -5,14 +5,13 @@ import { DownloadButton } from "@/sections/modals/PreviewModal/variants/shared";
export const unsupportedVariant: PreviewVariant = {
matches: () => true,
width: "md",
width: "lg",
height: "full",
needsTextContent: false,
codeBackground: false,
headerDescription: () => "",
renderContent: (ctx) => (
<div className="flex flex-col items-center justify-center flex-1 w-full min-h-0 gap-4 p-6">
<div className="flex flex-col items-center justify-center flex-1 min-h-0 gap-4 p-6">
<Text as="p" text03 mainUiBody>
This file format is not supported for preview.
</Text>

View File

@@ -121,6 +121,7 @@ const collections = (
{
name: "User Management",
items: [
sidebarItem(ADMIN_PATHS.USERS),
...(enableEnterprise ? [sidebarItem(ADMIN_PATHS.GROUPS)] : []),
sidebarItem(ADMIN_PATHS.API_KEYS),
sidebarItem(ADMIN_PATHS.TOKEN_RATE_LIMITS),
@@ -129,7 +130,8 @@ const collections = (
{
name: "Permissions",
items: [
sidebarItem(ADMIN_PATHS.USERS),
// TODO (nikolas): Uncommented in switchover PR once Users v2 is ready
// sidebarItem(ADMIN_PATHS.USERS_V2),
...(enableEnterprise ? [sidebarItem(ADMIN_PATHS.SCIM)] : []),
],
},

View File

@@ -260,7 +260,6 @@ module.exports = {
"code-string": "var(--code-string)",
"code-number": "var(--code-number)",
"code-definition": "var(--code-definition)",
"background-code-01": "var(--background-code-01)",
// Shimmer colors for loading animations
"shimmer-base": "var(--shimmer-base)",