mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-12 19:22:40 +00:00
Compare commits
4 Commits
main
...
nikg/fix-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fb8dc1db0 | ||
|
|
fda1528174 | ||
|
|
5f34c83f0a | ||
|
|
3509e9c48c |
60
.github/workflows/deployment.yml
vendored
60
.github/workflows/deployment.yml
vendored
@@ -29,32 +29,20 @@ jobs:
|
||||
build-backend-craft: ${{ steps.check.outputs.build-backend-craft }}
|
||||
build-model-server: ${{ steps.check.outputs.build-model-server }}
|
||||
is-cloud-tag: ${{ steps.check.outputs.is-cloud-tag }}
|
||||
is-stable: ${{ steps.check.outputs.is-stable }}
|
||||
is-beta: ${{ steps.check.outputs.is-beta }}
|
||||
is-stable-standalone: ${{ steps.check.outputs.is-stable-standalone }}
|
||||
is-beta-standalone: ${{ steps.check.outputs.is-beta-standalone }}
|
||||
is-latest: ${{ steps.check.outputs.is-latest }}
|
||||
is-craft-latest: ${{ steps.check.outputs.is-craft-latest }}
|
||||
is-test-run: ${{ steps.check.outputs.is-test-run }}
|
||||
sanitized-tag: ${{ steps.check.outputs.sanitized-tag }}
|
||||
short-sha: ${{ steps.check.outputs.short-sha }}
|
||||
steps:
|
||||
- name: Checkout (for git tags)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # ratchet:astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "0.9.9"
|
||||
enable-cache: false
|
||||
|
||||
- name: Check which components to build and version info
|
||||
id: check
|
||||
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 '/' '-')
|
||||
@@ -66,8 +54,9 @@ jobs:
|
||||
IS_VERSION_TAG=false
|
||||
IS_STABLE=false
|
||||
IS_BETA=false
|
||||
IS_STABLE_STANDALONE=false
|
||||
IS_BETA_STANDALONE=false
|
||||
IS_LATEST=false
|
||||
IS_CRAFT_LATEST=false
|
||||
IS_PROD_TAG=false
|
||||
IS_TEST_RUN=false
|
||||
BUILD_DESKTOP=false
|
||||
@@ -78,6 +67,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,28 +97,20 @@ 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_STABLE" == "true" ]] && [[ "$IS_CLOUD" != "true" ]]; then
|
||||
IS_STABLE_STANDALONE=true
|
||||
fi
|
||||
if [[ "$IS_BETA" == "true" ]] && [[ "$IS_CLOUD" != "true" ]]; then
|
||||
IS_BETA_STANDALONE=true
|
||||
fi
|
||||
|
||||
# Determine if this tag should get the "latest" Docker tag.
|
||||
# Only the highest semver stable tag (vX.Y.Z exactly) gets "latest".
|
||||
if [[ "$IS_STABLE" == "true" ]]; then
|
||||
HIGHEST_STABLE=$(uv run --no-sync --with onyx-devtools ods latest-stable-tag) || {
|
||||
echo "::error::Failed to determine highest stable tag via 'ods latest-stable-tag'"
|
||||
exit 1
|
||||
}
|
||||
if [[ "$TAG" == "$HIGHEST_STABLE" ]]; then
|
||||
IS_LATEST=true
|
||||
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
|
||||
@@ -145,9 +129,11 @@ jobs:
|
||||
echo "build-backend-craft=$BUILD_BACKEND_CRAFT"
|
||||
echo "build-model-server=$BUILD_MODEL_SERVER"
|
||||
echo "is-cloud-tag=$IS_CLOUD"
|
||||
echo "is-stable=$IS_STABLE"
|
||||
echo "is-beta=$IS_BETA"
|
||||
echo "is-stable-standalone=$IS_STABLE_STANDALONE"
|
||||
echo "is-beta-standalone=$IS_BETA_STANDALONE"
|
||||
echo "is-latest=$IS_LATEST"
|
||||
echo "is-craft-latest=$IS_CRAFT_LATEST"
|
||||
echo "is-test-run=$IS_TEST_RUN"
|
||||
echo "sanitized-tag=$SANITIZED_TAG"
|
||||
echo "short-sha=$SHORT_SHA"
|
||||
@@ -614,7 +600,7 @@ jobs:
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run == 'true' && format('web-{0}', needs.determine-builds.outputs.sanitized-tag) || github.ref_name }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-latest == 'true' && 'latest' || '' }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-stable == 'true' && 'latest' || '' }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && env.EDGE_TAG == 'true' && 'edge' || '' }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-beta == 'true' && 'beta' || '' }}
|
||||
|
||||
@@ -1051,7 +1037,7 @@ jobs:
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run == 'true' && format('backend-{0}', needs.determine-builds.outputs.sanitized-tag) || github.ref_name }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-latest == 'true' && 'latest' || '' }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-stable-standalone == 'true' && 'latest' || '' }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && env.EDGE_TAG == 'true' && 'edge' || '' }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-beta-standalone == 'true' && 'beta' || '' }}
|
||||
|
||||
@@ -1487,7 +1473,7 @@ jobs:
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run == 'true' && format('model-server-{0}', needs.determine-builds.outputs.sanitized-tag) || github.ref_name }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-latest == 'true' && 'latest' || '' }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-stable-standalone == 'true' && 'latest' || '' }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && env.EDGE_TAG == 'true' && 'edge' || '' }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-beta-standalone == 'true' && 'beta' || '' }}
|
||||
|
||||
|
||||
@@ -272,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"Graph API 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:
|
||||
@@ -357,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)
|
||||
@@ -372,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]
|
||||
@@ -397,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")
|
||||
@@ -441,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:
|
||||
@@ -1322,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:
|
||||
|
||||
@@ -76,11 +76,39 @@ def get_feedback_reminder_blocks(thread_link: str, include_followup: bool) -> Bl
|
||||
return SectionBlock(text=text)
|
||||
|
||||
|
||||
def _find_unclosed_fence(text: str) -> tuple[bool, int, str]:
|
||||
"""Scan *text* line-by-line to determine code-fence state.
|
||||
|
||||
Returns (is_open, fence_line_start, lang) where:
|
||||
- *is_open* is True when the text ends inside an unclosed code fence
|
||||
- *fence_line_start* is the char offset of the opening fence line
|
||||
(only meaningful when *is_open* is True)
|
||||
- *lang* is the language specifier on the opening fence (e.g. "python")
|
||||
"""
|
||||
in_fence = False
|
||||
fence_start = 0
|
||||
lang = ""
|
||||
offset = 0
|
||||
for line in text.splitlines(True): # keep line endings
|
||||
# Slack only treats ``` as a fence when it starts at column 0.
|
||||
# Indented backticks (e.g. inside a heredoc) are content, not fences.
|
||||
if line.startswith("```"):
|
||||
if not in_fence:
|
||||
in_fence = True
|
||||
fence_start = offset
|
||||
lang = line[3:].strip()
|
||||
else:
|
||||
in_fence = False
|
||||
lang = ""
|
||||
offset += len(line)
|
||||
return in_fence, fence_start, lang
|
||||
|
||||
|
||||
def _split_text(text: str, limit: int = 3000) -> list[str]:
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
|
||||
chunks = []
|
||||
chunks: list[str] = []
|
||||
while text:
|
||||
if len(text) <= limit:
|
||||
chunks.append(text)
|
||||
@@ -92,8 +120,37 @@ def _split_text(text: str, limit: int = 3000) -> list[str]:
|
||||
split_at = limit
|
||||
|
||||
chunk = text[:split_at]
|
||||
|
||||
# Check whether the chunk ends inside an unclosed code fence.
|
||||
is_open, fence_start, lang = _find_unclosed_fence(chunk)
|
||||
if is_open:
|
||||
# Tier 1: try to back up to before the opening fence so the
|
||||
# entire code block stays in the next chunk.
|
||||
split_before = text.rfind("\n", 0, fence_start)
|
||||
if split_before > 0 and text[:split_before].strip():
|
||||
chunk = text[:split_before]
|
||||
remainder = text[split_before:]
|
||||
# Strip only the leading newline to preserve blank lines
|
||||
# and formatting before the code fence.
|
||||
if remainder and remainder[0] == "\n":
|
||||
remainder = remainder[1:]
|
||||
text = remainder
|
||||
else:
|
||||
# Tier 2: the code block itself exceeds the limit — split
|
||||
# inside it. Close the fence here, reopen in the next.
|
||||
chunk += "\n```"
|
||||
remainder = text[split_at:]
|
||||
# Strip only the single boundary character (space/newline)
|
||||
# to avoid eating meaningful indentation inside code.
|
||||
if remainder and remainder[0] in " \n":
|
||||
remainder = remainder[1:]
|
||||
text = f"```{lang}\n" + remainder
|
||||
else:
|
||||
# No unclosed fence — plain prose split. Leading whitespace
|
||||
# is cosmetic in Slack mrkdwn, so lstrip() is safe here.
|
||||
text = text[split_at:].lstrip()
|
||||
|
||||
chunks.append(chunk)
|
||||
text = text[split_at:].lstrip() # Remove leading spaces from the next chunk
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import timeago # type: ignore
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.context.search.models import SavedSearchDoc
|
||||
from onyx.onyxbot.slack.blocks import _build_documents_blocks
|
||||
from onyx.onyxbot.slack.blocks import _find_unclosed_fence
|
||||
from onyx.onyxbot.slack.blocks import _split_text
|
||||
|
||||
|
||||
def _make_saved_doc(updated_at: datetime | None) -> SavedSearchDoc:
|
||||
@@ -69,3 +71,154 @@ def test_build_documents_blocks_formats_naive_timestamp(
|
||||
formatted_timestamp: datetime = captured["doc"]
|
||||
expected_timestamp: datetime = naive_timestamp.replace(tzinfo=pytz.utc)
|
||||
assert formatted_timestamp == expected_timestamp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _split_text tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSplitText:
|
||||
def test_short_text_returns_single_chunk(self) -> None:
|
||||
result = _split_text("hello world", limit=100)
|
||||
assert result == ["hello world"]
|
||||
|
||||
def test_splits_at_space_boundary(self) -> None:
|
||||
text = "aaa bbb ccc ddd"
|
||||
result = _split_text(text, limit=8)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_code_block_not_split_when_fits(self) -> None:
|
||||
# Text fits within limit — exercises the early-return path,
|
||||
# not the fence-aware splitting logic.
|
||||
text = "before ```code here``` after"
|
||||
result = _split_text(text, limit=100)
|
||||
assert result == [text]
|
||||
|
||||
def test_code_block_split_backs_up_before_fence(self) -> None:
|
||||
# Build text where the split point falls inside a code block,
|
||||
# but the code block itself fits within the limit. The split
|
||||
# should back up to before the opening ``` so the block stays intact.
|
||||
before = "some intro text here " * 5 + "\n" # ~105 chars
|
||||
code_content = "x " * 20 # ~40 chars of code
|
||||
text = f"{before}```\n{code_content}\n```\nafter"
|
||||
# limit=120 means the initial split lands inside the code block
|
||||
# but the code block (~50 chars) fits in the next chunk
|
||||
result = _split_text(text, limit=120)
|
||||
|
||||
assert len(result) >= 2
|
||||
# Every chunk must have balanced code fences
|
||||
for chunk in result:
|
||||
is_open, _, _ = _find_unclosed_fence(chunk)
|
||||
assert not is_open, f"Unclosed fence in chunk: {chunk[:80]}..."
|
||||
# The code block should be fully contained in one chunk
|
||||
code_chunks = [c for c in result if "```" in c]
|
||||
assert len(code_chunks) == 1, "Code block should not be split across chunks"
|
||||
|
||||
def test_no_code_fences_splits_normally(self) -> None:
|
||||
text = "word " * 100 # 500 chars
|
||||
result = _split_text(text, limit=100)
|
||||
assert len(result) >= 5
|
||||
for chunk in result:
|
||||
fence_count = chunk.count("```")
|
||||
assert fence_count == 0
|
||||
|
||||
def test_code_block_exceeding_limit_falls_back_to_close_reopen(self) -> None:
|
||||
# When the code block itself is bigger than the limit, we can't
|
||||
# avoid splitting inside it — verify fences are still balanced.
|
||||
code_content = "x " * 100 # ~200 chars
|
||||
text = f"```\n{code_content}\n```"
|
||||
result = _split_text(text, limit=80)
|
||||
|
||||
assert len(result) >= 2
|
||||
for chunk in result:
|
||||
is_open, _, _ = _find_unclosed_fence(chunk)
|
||||
assert not is_open, f"Unclosed fence in chunk: {chunk[:80]}..."
|
||||
|
||||
def test_code_block_exceeding_limit_no_spaces(self) -> None:
|
||||
# When code has no spaces, split_at is forced to limit.
|
||||
# Fences should still be balanced.
|
||||
code_content = "x" * 200
|
||||
text = f"```\n{code_content}\n```"
|
||||
result = _split_text(text, limit=80)
|
||||
|
||||
assert len(result) >= 2
|
||||
for chunk in result:
|
||||
is_open, _, _ = _find_unclosed_fence(chunk)
|
||||
assert not is_open, f"Unclosed fence in chunk: {chunk[:80]}..."
|
||||
|
||||
def test_all_content_preserved_after_split(self) -> None:
|
||||
text = "intro paragraph and more text here\n```\nprint('hello')\n```\nconclusion here"
|
||||
result = _split_text(text, limit=50)
|
||||
|
||||
# Key content should appear somewhere across the chunks
|
||||
joined = " ".join(result)
|
||||
assert "intro" in joined
|
||||
assert "print('hello')" in joined
|
||||
assert "conclusion" in joined
|
||||
|
||||
def test_language_specifier_preserved_on_reopen(self) -> None:
|
||||
# When a ```python block exceeds the limit and must be split,
|
||||
# the continuation chunk should reopen with ```python, not ```.
|
||||
code_content = "x " * 100 # ~200 chars
|
||||
text = f"```python\n{code_content}\n```"
|
||||
result = _split_text(text, limit=80)
|
||||
|
||||
assert len(result) >= 2
|
||||
for chunk in result[1:]:
|
||||
stripped = chunk.lstrip()
|
||||
if stripped.startswith("```"):
|
||||
assert stripped.startswith(
|
||||
"```python"
|
||||
), f"Language specifier lost in continuation: {chunk[:40]}"
|
||||
|
||||
def test_inline_backticks_inside_code_block_ignored(self) -> None:
|
||||
# Triple backticks appearing mid-line inside a code block should
|
||||
# not be mistaken for fence boundaries.
|
||||
before = "some text here " * 6 + "\n" # ~90 chars
|
||||
text = f"{before}```bash\necho '```'\necho done\n```\nafter"
|
||||
result = _split_text(text, limit=110)
|
||||
|
||||
assert len(result) >= 2
|
||||
for chunk in result:
|
||||
is_open, _, _ = _find_unclosed_fence(chunk)
|
||||
assert not is_open, f"Chunk has unclosed fence: {chunk[:80]}..."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _find_unclosed_fence tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindUnclosedFence:
|
||||
def test_no_fences(self) -> None:
|
||||
is_open, _, _ = _find_unclosed_fence("just plain text")
|
||||
assert not is_open
|
||||
|
||||
def test_balanced_fences(self) -> None:
|
||||
is_open, _, _ = _find_unclosed_fence("```\ncode\n```")
|
||||
assert not is_open
|
||||
|
||||
def test_unclosed_fence(self) -> None:
|
||||
is_open, start, lang = _find_unclosed_fence("before\n```\ncode here")
|
||||
assert is_open
|
||||
assert start == len("before\n")
|
||||
assert lang == ""
|
||||
|
||||
def test_unclosed_fence_with_lang(self) -> None:
|
||||
is_open, _, lang = _find_unclosed_fence("intro\n```python\ncode")
|
||||
assert is_open
|
||||
assert lang == "python"
|
||||
|
||||
def test_inline_backticks_not_counted(self) -> None:
|
||||
# Backticks mid-line should not toggle fence state
|
||||
text = "```bash\necho '```'\necho done\n```"
|
||||
is_open, _, _ = _find_unclosed_fence(text)
|
||||
assert not is_open
|
||||
|
||||
def test_indented_backticks_not_counted_as_fence(self) -> None:
|
||||
# Slack only treats ``` at column 0 as a fence delimiter.
|
||||
# Indented backticks inside a code block are content, not fences.
|
||||
text = "```bash\ncat <<'EOF'\n ```\nEOF\n```"
|
||||
is_open, _, _ = _find_unclosed_fence(text)
|
||||
assert not is_open
|
||||
|
||||
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jmelahman/tag/git"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
@@ -113,11 +112,11 @@ export default function useAdminUsers() {
|
||||
const isLoading = acceptedLoading || invitedLoading || requestedLoading;
|
||||
const error = acceptedError ?? invitedError ?? requestedError;
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
function refresh() {
|
||||
acceptedMutate();
|
||||
invitedMutate();
|
||||
requestedMutate();
|
||||
}, [acceptedMutate, invitedMutate, requestedMutate]);
|
||||
}
|
||||
|
||||
return { users, isLoading, error, refresh };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, noProp } from "@/lib/utils";
|
||||
import LineItem, { LineItemProps } from "@/refresh-components/buttons/LineItem";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import type { IconProps } from "@opal/types";
|
||||
@@ -298,10 +298,7 @@ function InputSelectContent({
|
||||
)}
|
||||
sideOffset={4}
|
||||
position="popper"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
onMouseDown={noProp()}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport className="flex flex-col gap-1">
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@opal/components";
|
||||
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 } from "./svc";
|
||||
import type { UserRow } from "./interfaces";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ModalType = "deactivate" | "activate" | "delete" | null;
|
||||
|
||||
interface UserRowActionsProps {
|
||||
user: UserRow;
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function UserRowActions({
|
||||
user,
|
||||
onMutate,
|
||||
}: UserRowActionsProps) {
|
||||
const [modal, setModal] = useState<ModalType>(null);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
async function handleAction(
|
||||
action: () => Promise<void>,
|
||||
successMessage: string
|
||||
) {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await action();
|
||||
onMutate();
|
||||
toast.success(successMessage);
|
||||
setModal(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (user.is_scim_synced) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<Button prominence="tertiary" icon={SvgMoreHorizontal} />
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="end">
|
||||
<div className="flex flex-col gap-0.5 p-1">
|
||||
{user.status === UserStatus.ACTIVE ? (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgXCircle}
|
||||
onClick={() => {
|
||||
setPopoverOpen(false);
|
||||
setModal("deactivate");
|
||||
}}
|
||||
>
|
||||
Deactivate User
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<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
|
||||
icon={SvgXCircle}
|
||||
title="Deactivate User"
|
||||
onClose={isSubmitting ? undefined : () => setModal(null)}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={async () => {
|
||||
await handleAction(
|
||||
() => deactivateUser(user.email),
|
||||
"User deactivated"
|
||||
);
|
||||
}}
|
||||
>
|
||||
Deactivate
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
<Text as="span" text05>
|
||||
{user.email}
|
||||
</Text>{" "}
|
||||
will immediately lose access to Onyx. Their sessions and agents will
|
||||
be preserved. You can reactivate this account later.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
{modal === "activate" && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgCheck}
|
||||
title="Activate User"
|
||||
onClose={isSubmitting ? undefined : () => setModal(null)}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await handleAction(
|
||||
() => activateUser(user.email),
|
||||
"User activated"
|
||||
);
|
||||
}}
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
<Text as="span" text05>
|
||||
{user.email}
|
||||
</Text>{" "}
|
||||
will regain access to Onyx.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
{modal === "delete" && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgTrash}
|
||||
title="Delete User"
|
||||
onClose={isSubmitting ? undefined : () => setModal(null)}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={async () => {
|
||||
await handleAction(
|
||||
() => deleteUser(user.email),
|
||||
"User deleted"
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
<Text as="span" text05>
|
||||
{user.email}
|
||||
</Text>{" "}
|
||||
will be permanently removed from Onyx. All of their session history
|
||||
will be deleted. This cannot be undone.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import useAdminUsers from "@/hooks/useAdminUsers";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import UserFilters from "./UserFilters";
|
||||
import UserRowActions from "./UserRowActions";
|
||||
import type {
|
||||
UserRow,
|
||||
UserGroupInfo,
|
||||
@@ -134,54 +133,50 @@ function renderLastUpdatedColumn(value: string | null) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Columns
|
||||
// Columns (stable reference — defined at module scope)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tc = createTableColumns<UserRow>();
|
||||
|
||||
function buildColumns(onMutate: () => void) {
|
||||
return [
|
||||
tc.qualifier({
|
||||
content: "avatar-user",
|
||||
getInitials: (row) => getInitials(row.personal_name, row.email),
|
||||
selectable: false,
|
||||
}),
|
||||
tc.column("email", {
|
||||
header: "Name",
|
||||
weight: 22,
|
||||
minWidth: 140,
|
||||
cell: renderNameColumn,
|
||||
}),
|
||||
tc.column("groups", {
|
||||
header: "Groups",
|
||||
weight: 24,
|
||||
minWidth: 200,
|
||||
enableSorting: false,
|
||||
cell: renderGroupsColumn,
|
||||
}),
|
||||
tc.column("role", {
|
||||
header: "Account Type",
|
||||
weight: 16,
|
||||
minWidth: 180,
|
||||
cell: renderRoleColumn,
|
||||
}),
|
||||
tc.column("status", {
|
||||
header: "Status",
|
||||
weight: 14,
|
||||
minWidth: 100,
|
||||
cell: renderStatusColumn,
|
||||
}),
|
||||
tc.column("updated_at", {
|
||||
header: "Last Updated",
|
||||
weight: 14,
|
||||
minWidth: 100,
|
||||
cell: renderLastUpdatedColumn,
|
||||
}),
|
||||
tc.actions({
|
||||
cell: (row) => <UserRowActions user={row} onMutate={onMutate} />,
|
||||
}),
|
||||
];
|
||||
}
|
||||
const columns = [
|
||||
tc.qualifier({
|
||||
content: "avatar-user",
|
||||
getInitials: (row) => getInitials(row.personal_name, row.email),
|
||||
selectable: false,
|
||||
}),
|
||||
tc.column("email", {
|
||||
header: "Name",
|
||||
weight: 22,
|
||||
minWidth: 140,
|
||||
cell: renderNameColumn,
|
||||
}),
|
||||
tc.column("groups", {
|
||||
header: "Groups",
|
||||
weight: 24,
|
||||
minWidth: 200,
|
||||
enableSorting: false,
|
||||
cell: renderGroupsColumn,
|
||||
}),
|
||||
tc.column("role", {
|
||||
header: "Account Type",
|
||||
weight: 16,
|
||||
minWidth: 180,
|
||||
cell: renderRoleColumn,
|
||||
}),
|
||||
tc.column("status", {
|
||||
header: "Status",
|
||||
weight: 14,
|
||||
minWidth: 100,
|
||||
cell: renderStatusColumn,
|
||||
}),
|
||||
tc.column("updated_at", {
|
||||
header: "Last Updated",
|
||||
weight: 14,
|
||||
minWidth: 100,
|
||||
cell: renderLastUpdatedColumn,
|
||||
}),
|
||||
tc.actions(),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
@@ -218,9 +213,7 @@ export default function UsersTable({
|
||||
[allGroups]
|
||||
);
|
||||
|
||||
const { users, isLoading, error, refresh } = useAdminUsers();
|
||||
|
||||
const columns = useMemo(() => buildColumns(refresh), [refresh]);
|
||||
const { users, isLoading, error } = useAdminUsers();
|
||||
|
||||
// Client-side filtering
|
||||
const filteredUsers = useMemo(() => {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
async function parseErrorDetail(
|
||||
res: Response,
|
||||
fallback: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const body = await res.json();
|
||||
return body?.detail ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deactivateUser(email: string): Promise<void> {
|
||||
const res = await fetch("/api/manage/admin/deactivate-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 deactivate user"));
|
||||
}
|
||||
}
|
||||
|
||||
export async function activateUser(email: string): Promise<void> {
|
||||
const res = await fetch("/api/manage/admin/activate-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 activate user"));
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUser(email: string): Promise<void> {
|
||||
const res = await fetch("/api/manage/admin/delete-user", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_email: email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorDetail(res, "Failed to delete user"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user