Compare commits

..

4 Commits

Author SHA1 Message Date
Nik
3fb8dc1db0 fix(slackbot): only treat column-0 backticks as fences, add no-space test
- _find_unclosed_fence now uses line.startswith("```") instead of
  line.lstrip().startswith("```") — Slack only renders fences at column 0,
  so indented backticks inside code blocks are correctly treated as content
- Add clarifying comment on else branch lstrip() explaining why it's safe
- Add test for Tier 2 forced-split with no spaces in code content
- Add test for indented backticks not being counted as fences
- Clarify test_code_block_not_split_when_fits exercises early-return path
2026-03-12 11:58:50 -07:00
Nik
fda1528174 fix(slackbot): use _find_unclosed_fence in tests, strip only leading newline in Tier 1
- Replace count("```") % 2 assertions in tests with _find_unclosed_fence
  for consistency with production code
- Tier 1 now strips only the leading newline instead of lstrip() to
  preserve blank lines and formatting before code fences
2026-03-12 11:43:48 -07:00
Nik
5f34c83f0a fix(slackbot): address review — robust fence detection and lang preservation
- Replace naive `count("```")` with line-by-line `_find_unclosed_fence()`
  that only considers fences at the start of a line, fixing false positives
  from inline backticks inside code blocks
- Preserve language specifier (e.g. ```python) when reopening fences in
  Tier 2 fallback
- Guard against whitespace-only chunks in Tier 1 backup
- Strip only the boundary character in Tier 2 instead of lstrip() to
  preserve meaningful code indentation
- Add tests for language specifier preservation, inline backtick handling,
  and _find_unclosed_fence helper
2026-03-12 11:12:28 -07:00
Nik
3509e9c48c fix(slackbot): close code fences when splitting long messages
When a Slack bot response exceeds 3000 chars, _split_text splits it
into multiple SectionBlocks. If the split lands inside a code fence,
the opening ``` ends up in one block and the closing ``` in the next,
causing Slack to render everything after the cut as raw code.

Now detects unclosed fences at the split point, closes them in the
current chunk and reopens in the next so both render correctly.
2026-03-12 09:25:03 -07:00
11 changed files with 289 additions and 366 deletions

View File

@@ -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' || '' }}

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

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

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

@@ -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 };
}

View File

@@ -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">

View File

@@ -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>
)}
</>
);
}

View File

@@ -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(() => {

View File

@@ -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"));
}
}