Compare commits

..

8 Commits

Author SHA1 Message Date
github-actions[bot]
b4579a1365 fix(indexing, powerpoint files): Patch markitdown _convert_chart_to_markdown to no-op (#9970) to release v3.1 (#9979)
Co-authored-by: acaprau <48705707+acaprau@users.noreply.github.com>
2026-04-07 13:02:34 -07:00
Justin Tahara
893c094aed fix(groups): Global Curator Permissions (#9974) 2026-04-07 13:01:38 -07:00
Wenxi
f8a55712d2 fix: set correct ee mode for mcp server (#9933) 2026-04-07 09:13:23 -07:00
github-actions[bot]
591afd4fb1 fix: stop falsely rejecting owner-password-only PDFs as protected (#9953) to release v3.1 (#9962)
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:23:13 -07:00
github-actions[bot]
9328070dc0 fix(federated): prevent masked credentials from corrupting stored secrets (#9868) to release v3.1 (#9928) 2026-04-05 16:18:14 -07:00
Jamison Lahman
6163521126 Revert "chore(deps): bump litellm from 1.81.6 to 1.83.0 (#9898) to release v3.1" (#9910) 2026-04-03 18:32:10 -07:00
Jamison Lahman
d42c5616b0 chore(deps): bump litellm from 1.81.6 to 1.83.0 (#9898) to release v3.1 (#9902)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 16:18:19 -07:00
Justin Tahara
aeb4fdd6c1 fix(db): remove unnecessary selectinload(User.memories) from auth paths (#9838) 2026-04-01 17:16:57 -07:00
20 changed files with 529 additions and 99 deletions

View File

@@ -12,6 +12,11 @@ SLACK_USER_TOKEN_PREFIX = "xoxp-"
SLACK_BOT_TOKEN_PREFIX = "xoxb-"
ONYX_EMAILABLE_LOGO_MAX_DIM = 512
# The mask_string() function in encryption.py uses "•" (U+2022 BULLET) to mask secrets.
MASK_CREDENTIAL_CHAR = "\u2022"
# Pattern produced by mask_string for strings >= 14 chars: "abcd...wxyz" (exactly 11 chars)
MASK_CREDENTIAL_LONG_RE = re.compile(r"^.{4}\.{3}.{4}$")
SOURCE_TYPE = "source_type"
# stored in the `metadata` of a chunk. Used to signify that this chunk should
# not be used for QA. For example, Google Drive file types which can't be parsed

View File

@@ -4,7 +4,6 @@ from fastapi_users.password import PasswordHelper
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.auth.api_key import ApiKeyDescriptor
@@ -55,7 +54,6 @@ async def fetch_user_for_api_key(
select(User)
.join(ApiKey, ApiKey.user_id == User.id)
.where(ApiKey.hashed_api_key == hashed_api_key)
.options(selectinload(User.memories))
)

View File

@@ -13,7 +13,6 @@ from sqlalchemy import func
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.auth.schemas import UserRole
@@ -98,11 +97,6 @@ async def get_user_count(only_admin_users: bool = False) -> int:
# Need to override this because FastAPI Users doesn't give flexibility for backend field creation logic in OAuth flow
class SQLAlchemyUserAdminDB(SQLAlchemyUserDatabase[UP, ID]):
async def _get_user(self, statement: Select) -> UP | None:
statement = statement.options(selectinload(User.memories))
results = await self.session.execute(statement)
return results.unique().scalar_one_or_none()
async def create(
self,
create_dict: Dict[str, Any],

View File

@@ -8,6 +8,8 @@ from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.configs.constants import FederatedConnectorSource
from onyx.configs.constants import MASK_CREDENTIAL_CHAR
from onyx.configs.constants import MASK_CREDENTIAL_LONG_RE
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.models import DocumentSet
from onyx.db.models import FederatedConnector
@@ -45,6 +47,23 @@ def fetch_all_federated_connectors_parallel() -> list[FederatedConnector]:
return fetch_all_federated_connectors(db_session)
def _reject_masked_credentials(credentials: dict[str, Any]) -> None:
"""Raise if any credential string value contains mask placeholder characters.
mask_string() has two output formats:
- Short strings (< 14 chars): "••••••••••••" (U+2022 BULLET)
- Long strings (>= 14 chars): "abcd...wxyz" (first4 + "..." + last4)
Both must be rejected.
"""
for key, val in credentials.items():
if isinstance(val, str) and (
MASK_CREDENTIAL_CHAR in val or MASK_CREDENTIAL_LONG_RE.match(val)
):
raise ValueError(
f"Credential field '{key}' contains masked placeholder characters. Please provide the actual credential value."
)
def validate_federated_connector_credentials(
source: FederatedConnectorSource,
credentials: dict[str, Any],
@@ -66,6 +85,8 @@ def create_federated_connector(
config: dict[str, Any] | None = None,
) -> FederatedConnector:
"""Create a new federated connector with credential and config validation."""
_reject_masked_credentials(credentials)
# Validate credentials before creating
if not validate_federated_connector_credentials(source, credentials):
raise ValueError(
@@ -277,6 +298,8 @@ def update_federated_connector(
)
if credentials is not None:
_reject_masked_credentials(credentials)
# Validate credentials before updating
if not validate_federated_connector_credentials(
federated_connector.source, credentials

View File

@@ -8,7 +8,6 @@ from uuid import UUID
from sqlalchemy import select
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.auth.pat import build_displayable_pat
@@ -47,7 +46,6 @@ async def fetch_user_for_pat(
(PersonalAccessToken.expires_at.is_(None))
| (PersonalAccessToken.expires_at > now)
)
.options(selectinload(User.memories))
)
if not user:
return None

View File

@@ -229,7 +229,9 @@ def get_memories_for_user(
user_id: UUID,
db_session: Session,
) -> Sequence[Memory]:
return db_session.scalars(select(Memory).where(Memory.user_id == user_id)).all()
return db_session.scalars(
select(Memory).where(Memory.user_id == user_id).order_by(Memory.id.desc())
).all()
def update_user_pinned_assistants(

View File

@@ -49,9 +49,21 @@ KNOWN_OPENPYXL_BUGS = [
def get_markitdown_converter() -> "MarkItDown":
global _MARKITDOWN_CONVERTER
from markitdown import MarkItDown
if _MARKITDOWN_CONVERTER is None:
from markitdown import MarkItDown
# Patch this function to effectively no-op because we were seeing this
# module take an inordinate amount of time to convert charts to markdown,
# making some powerpoint files with many or complicated charts nearly
# unindexable.
from markitdown.converters._pptx_converter import PptxConverter
setattr(
PptxConverter,
"_convert_chart_to_markdown",
lambda self, chart: "\n\n[chart omitted]\n\n", # noqa: ARG005
)
_MARKITDOWN_CONVERTER = MarkItDown(enable_plugins=False)
return _MARKITDOWN_CONVERTER
@@ -202,18 +214,26 @@ def read_pdf_file(
try:
pdf_reader = PdfReader(file)
if pdf_reader.is_encrypted and pdf_pass is not None:
if pdf_reader.is_encrypted:
# Try the explicit password first, then fall back to an empty
# string. Owner-password-only PDFs (permission restrictions but
# no open password) decrypt successfully with "".
# See https://github.com/onyx-dot-app/onyx/issues/9754
passwords = [p for p in [pdf_pass, ""] if p is not None]
decrypt_success = False
try:
decrypt_success = pdf_reader.decrypt(pdf_pass) != 0
except Exception:
logger.error("Unable to decrypt pdf")
for pw in passwords:
try:
if pdf_reader.decrypt(pw) != 0:
decrypt_success = True
break
except Exception:
pass
if not decrypt_success:
logger.error(
"Encrypted PDF could not be decrypted, returning empty text."
)
return "", metadata, []
elif pdf_reader.is_encrypted:
logger.warning("No Password for an encrypted PDF, returning empty text.")
return "", metadata, []
# Basic PDF metadata
if pdf_reader.metadata is not None:

View File

@@ -33,8 +33,20 @@ def is_pdf_protected(file: IO[Any]) -> bool:
with preserve_position(file):
reader = PdfReader(file)
if not reader.is_encrypted:
return False
return bool(reader.is_encrypted)
# PDFs with only an owner password (permission restrictions like
# print/copy disabled) use an empty user password — any viewer can open
# them without prompting. decrypt("") returns 0 only when a real user
# password is required. See https://github.com/onyx-dot-app/onyx/issues/9754
try:
return reader.decrypt("") == 0
except Exception:
logger.exception(
"Failed to evaluate PDF encryption; treating as password protected"
)
return True
def is_docx_protected(file: IO[Any]) -> bool:

View File

@@ -6,6 +6,7 @@ from onyx.configs.app_configs import MCP_SERVER_ENABLED
from onyx.configs.app_configs import MCP_SERVER_HOST
from onyx.configs.app_configs import MCP_SERVER_PORT
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import set_is_ee_based_on_env_variable
logger = setup_logger()
@@ -16,6 +17,7 @@ def main() -> None:
logger.info("MCP server is disabled (MCP_SERVER_ENABLED=false)")
return
set_is_ee_based_on_env_variable()
logger.info(f"Starting MCP server on {MCP_SERVER_HOST}:{MCP_SERVER_PORT}")
from onyx.mcp_server.api import mcp_app

View File

@@ -147,6 +147,7 @@ class UserInfo(BaseModel):
is_anonymous_user: bool | None = None,
tenant_info: TenantInfo | None = None,
assistant_specific_configs: UserSpecificAssistantPreferences | None = None,
memories: list[MemoryItem] | None = None,
) -> "UserInfo":
return cls(
id=str(user.id),
@@ -191,10 +192,7 @@ class UserInfo(BaseModel):
role=user.personal_role or "",
use_memories=user.use_memories,
enable_memory_tool=user.enable_memory_tool,
memories=[
MemoryItem(id=memory.id, content=memory.memory_text)
for memory in (user.memories or [])
],
memories=memories or [],
user_preferences=user.user_preferences or "",
),
)

View File

@@ -57,6 +57,7 @@ from onyx.db.user_preferences import activate_user
from onyx.db.user_preferences import deactivate_user
from onyx.db.user_preferences import get_all_user_assistant_specific_configs
from onyx.db.user_preferences import get_latest_access_token_for_user
from onyx.db.user_preferences import get_memories_for_user
from onyx.db.user_preferences import update_assistant_preferences
from onyx.db.user_preferences import update_user_assistant_visibility
from onyx.db.user_preferences import update_user_auto_scroll
@@ -823,6 +824,11 @@ def verify_user_logged_in(
[],
),
)
memories = [
MemoryItem(id=memory.id, content=memory.memory_text)
for memory in get_memories_for_user(user.id, db_session)
]
user_info = UserInfo.from_model(
user,
current_token_created_at=token_created_at,
@@ -833,6 +839,7 @@ def verify_user_logged_in(
new_tenant=new_tenant,
invitation=tenant_invitation,
),
memories=memories,
)
return user_info
@@ -930,7 +937,8 @@ def update_user_personalization_api(
else user.enable_memory_tool
)
existing_memories = [
MemoryItem(id=memory.id, content=memory.memory_text) for memory in user.memories
MemoryItem(id=memory.id, content=memory.memory_text)
for memory in get_memories_for_user(user.id, db_session)
]
new_memories = (
request.memories if request.memories is not None else existing_memories

View File

@@ -0,0 +1,58 @@
import pytest
from onyx.configs.constants import MASK_CREDENTIAL_CHAR
from onyx.db.federated import _reject_masked_credentials
class TestRejectMaskedCredentials:
"""Verify that masked credential values are never accepted for DB writes.
mask_string() has two output formats:
- Short strings (< 14 chars): "••••••••••••" (U+2022 BULLET)
- Long strings (>= 14 chars): "abcd...wxyz" (first4 + "..." + last4)
_reject_masked_credentials must catch both.
"""
def test_rejects_fully_masked_value(self) -> None:
masked = MASK_CREDENTIAL_CHAR * 12 # "••••••••••••"
with pytest.raises(ValueError, match="masked placeholder"):
_reject_masked_credentials({"client_id": masked})
def test_rejects_long_string_masked_value(self) -> None:
"""mask_string returns 'first4...last4' for long strings — the real
format used for OAuth credentials like client_id and client_secret."""
with pytest.raises(ValueError, match="masked placeholder"):
_reject_masked_credentials({"client_id": "1234...7890"})
def test_rejects_when_any_field_is_masked(self) -> None:
"""Even if client_id is real, a masked client_secret must be caught."""
with pytest.raises(ValueError, match="client_secret"):
_reject_masked_credentials(
{
"client_id": "1234567890.1234567890",
"client_secret": MASK_CREDENTIAL_CHAR * 12,
}
)
def test_accepts_real_credentials(self) -> None:
# Should not raise
_reject_masked_credentials(
{
"client_id": "1234567890.1234567890",
"client_secret": "test_client_secret_value",
}
)
def test_accepts_empty_dict(self) -> None:
# Should not raise — empty credentials are handled elsewhere
_reject_masked_credentials({})
def test_ignores_non_string_values(self) -> None:
# Non-string values (None, bool, int) should pass through
_reject_masked_credentials(
{
"client_id": "real_value",
"redirect_uri": None,
"some_flag": True,
}
)

View File

@@ -0,0 +1,76 @@
%PDF-1.3
%<25><><EFBFBD><EFBFBD>
1 0 obj
<<
/Producer <1083d595b1>
>>
endobj
2 0 obj
<<
/Type /Pages
/Count 1
/Kids [ 4 0 R ]
>>
endobj
3 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj
4 0 obj
<<
/Type /Page
/Resources <<
/Font <<
/F1 <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
/MediaBox [ 0.0 0.0 200 200 ]
/Contents 5 0 R
/Parent 2 0 R
>>
endobj
5 0 obj
<<
/Length 42
>>
stream
,N<><6~<7E>)<29><><EFBFBD><EFBFBD><EFBFBD>u<EFBFBD> <0C><><EFBFBD>Zc'<27><>>8g<38><67><EFBFBD>n<EFBFBD><6E><EFBFBD><EFBFBD><EFBFBD>9"
endstream
endobj
6 0 obj
<<
/V 2
/R 3
/Length 128
/P 4294967292
/Filter /Standard
/O <6a340a292629053da84a6d8b19a5d505953b8b3fdac3d2d389fde0e354528d44>
/U <d6f0dc91c7b9de264a8d708515468e6528bf4e5e4e758a4164004e56fffa0108>
>>
endobj
xref
0 7
0000000000 65535 f
0000000015 00000 n
0000000059 00000 n
0000000118 00000 n
0000000167 00000 n
0000000348 00000 n
0000000440 00000 n
trailer
<<
/Size 7
/Root 3 0 R
/Info 1 0 R
/ID [ <6364336635356135633239323638353039306635656133623165313637366430> <6364336635356135633239323638353039306635656133623165313637366430> ]
/Encrypt 6 0 R
>>
startxref
655
%%EOF

View File

@@ -54,6 +54,12 @@ class TestReadPdfFile:
text, _, _ = read_pdf_file(_load("encrypted.pdf"), pdf_pass="wrong")
assert text == ""
def test_owner_password_only_pdf_extracts_text(self) -> None:
"""A PDF encrypted with only an owner password (no user password)
should still yield its text content. Regression for #9754."""
text, _, _ = read_pdf_file(_load("owner_protected.pdf"))
assert "Hello World" in text
def test_empty_pdf(self) -> None:
text, _, _ = read_pdf_file(_load("empty.pdf"))
assert text.strip() == ""
@@ -117,6 +123,12 @@ class TestIsPdfProtected:
def test_protected_pdf(self) -> None:
assert is_pdf_protected(_load("encrypted.pdf")) is True
def test_owner_password_only_is_not_protected(self) -> None:
"""A PDF with only an owner password (permission restrictions) but no
user password should NOT be considered protected — any viewer can open
it without prompting for a password."""
assert is_pdf_protected(_load("owner_protected.pdf")) is False
def test_preserves_file_position(self) -> None:
pdf = _load("simple.pdf")
pdf.seek(42)

View File

@@ -0,0 +1,79 @@
import io
from pptx import Presentation # type: ignore[import-untyped]
from pptx.chart.data import CategoryChartData # type: ignore[import-untyped]
from pptx.enum.chart import XL_CHART_TYPE # type: ignore[import-untyped]
from pptx.util import Inches # type: ignore[import-untyped]
from onyx.file_processing.extract_file_text import pptx_to_text
def _make_pptx_with_chart() -> io.BytesIO:
"""Create an in-memory pptx with one text slide and one chart slide."""
prs = Presentation()
# Slide 1: text only
slide1 = prs.slides.add_slide(prs.slide_layouts[1])
slide1.shapes.title.text = "Introduction"
slide1.placeholders[1].text = "This is the first slide."
# Slide 2: chart
slide2 = prs.slides.add_slide(prs.slide_layouts[5]) # Blank layout
chart_data = CategoryChartData()
chart_data.categories = ["Q1", "Q2", "Q3"]
chart_data.add_series("Revenue", (100, 200, 300))
slide2.shapes.add_chart(
XL_CHART_TYPE.COLUMN_CLUSTERED,
Inches(1),
Inches(1),
Inches(6),
Inches(4),
chart_data,
)
buf = io.BytesIO()
prs.save(buf)
buf.seek(0)
return buf
def _make_pptx_without_chart() -> io.BytesIO:
"""Create an in-memory pptx with a single text-only slide."""
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[1])
slide.shapes.title.text = "Hello World"
slide.placeholders[1].text = "Some content here."
buf = io.BytesIO()
prs.save(buf)
buf.seek(0)
return buf
class TestPptxToText:
def test_chart_is_omitted(self) -> None:
# Precondition
pptx_file = _make_pptx_with_chart()
# Under test
result = pptx_to_text(pptx_file)
# Postcondition
assert "Introduction" in result
assert "first slide" in result
assert "[chart omitted]" in result
# The actual chart data should NOT appear in the output.
assert "Revenue" not in result
assert "Q1" not in result
def test_text_only_pptx(self) -> None:
# Precondition
pptx_file = _make_pptx_without_chart()
# Under test
result = pptx_to_text(pptx_file)
# Postcondition
assert "Hello World" in result
assert "Some content" in result
assert "[chart omitted]" not in result

View File

@@ -70,6 +70,10 @@ backend = [
"lazy_imports==1.0.1",
"lxml==5.3.0",
"Mako==1.2.4",
# NOTE: Do not update without understanding the patching behavior in
# get_markitdown_converter in
# backend/onyx/file_processing/extract_file_text.py and what impacts
# updating might have on this behavior.
"markitdown[pdf, docx, pptx, xlsx, xls]==0.1.2",
"mcp[cli]==1.26.0",
"msal==1.34.0",

View File

@@ -133,7 +133,7 @@ async function createFederatedConnector(
async function updateFederatedConnector(
id: number,
credentials: CredentialForm,
credentials: CredentialForm | null,
config?: ConfigForm
): Promise<{ success: boolean; message: string }> {
try {
@@ -143,7 +143,7 @@ async function updateFederatedConnector(
"Content-Type": "application/json",
},
body: JSON.stringify({
credentials,
credentials: credentials ?? undefined,
config: config || {},
}),
});
@@ -201,7 +201,9 @@ export function FederatedConnectorForm({
const isEditMode = connectorId !== undefined;
const [formState, setFormState] = useState<FormState>({
credentials: preloadedConnectorData?.credentials || {},
// In edit mode, don't populate credentials with masked values from the API.
// Masked values (e.g. "••••••••••••") would be saved back and corrupt the real credentials.
credentials: isEditMode ? {} : preloadedConnectorData?.credentials || {},
config: preloadedConnectorData?.config || {},
schema: preloadedCredentialSchema?.credentials || null,
configurationSchema: null,
@@ -209,6 +211,7 @@ export function FederatedConnectorForm({
configurationSchemaError: null,
connectorError: null,
});
const [credentialsModified, setCredentialsModified] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitMessage, setSubmitMessage] = useState<string | null>(null);
const [submitSuccess, setSubmitSuccess] = useState<boolean | null>(null);
@@ -333,6 +336,7 @@ export function FederatedConnectorForm({
}
const handleCredentialChange = (key: string, value: string) => {
setCredentialsModified(true);
setFormState((prev) => ({
...prev,
credentials: {
@@ -354,6 +358,11 @@ export function FederatedConnectorForm({
const handleValidateCredentials = async () => {
if (!formState.schema) return;
if (isEditMode && !credentialsModified) {
setSubmitMessage("Enter new credential values before validating.");
setSubmitSuccess(false);
return;
}
setIsValidating(true);
setSubmitMessage(null);
@@ -411,8 +420,10 @@ export function FederatedConnectorForm({
setSubmitSuccess(null);
try {
// Validate required fields
if (formState.schema) {
const shouldValidateCredentials = !isEditMode || credentialsModified;
// Validate required fields (skip for credentials in edit mode when unchanged)
if (formState.schema && shouldValidateCredentials) {
const missingRequired = Object.entries(formState.schema)
.filter(
([key, field]) => field.required && !formState.credentials[key]
@@ -442,16 +453,20 @@ export function FederatedConnectorForm({
}
setConfigValidationErrors({});
// Validate credentials before creating/updating
const validation = await validateCredentials(
connector,
formState.credentials
);
if (!validation.success) {
setSubmitMessage(`Credential validation failed: ${validation.message}`);
setSubmitSuccess(false);
setIsSubmitting(false);
return;
// Validate credentials before creating/updating (skip in edit mode when unchanged)
if (shouldValidateCredentials) {
const validation = await validateCredentials(
connector,
formState.credentials
);
if (!validation.success) {
setSubmitMessage(
`Credential validation failed: ${validation.message}`
);
setSubmitSuccess(false);
setIsSubmitting(false);
return;
}
}
// Create or update the connector
@@ -459,7 +474,7 @@ export function FederatedConnectorForm({
isEditMode && connectorId
? await updateFederatedConnector(
connectorId,
formState.credentials,
credentialsModified ? formState.credentials : null,
formState.config
)
: await createFederatedConnector(
@@ -538,14 +553,16 @@ export function FederatedConnectorForm({
id={fieldKey}
type={fieldSpec.secret ? "password" : "text"}
placeholder={
fieldSpec.example
? String(fieldSpec.example)
: fieldSpec.description
isEditMode && !credentialsModified
? "•••••••• (leave blank to keep current value)"
: fieldSpec.example
? String(fieldSpec.example)
: fieldSpec.description
}
value={formState.credentials[fieldKey] || ""}
onChange={(e) => handleCredentialChange(fieldKey, e.target.value)}
className="w-96"
required={fieldSpec.required}
required={!isEditMode && fieldSpec.required}
/>
</div>
))}

View File

@@ -1,8 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import useSWR from "swr";
import { Table, Button } from "@opal/components";
import { IllustrationContent } from "@opal/layouts";
import { SvgUsers } from "@opal/icons";
@@ -14,16 +13,14 @@ import Text from "@/refresh-components/texts/Text";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import Separator from "@/refresh-components/Separator";
import { toast } from "@/hooks/useToast";
import { errorHandlingFetcher } from "@/lib/fetcher";
import useAdminUsers from "@/hooks/useAdminUsers";
import type { ApiKeyDescriptor, MemberRow } from "./interfaces";
import useGroupMemberCandidates from "./useGroupMemberCandidates";
import {
createGroup,
updateAgentGroupSharing,
updateDocSetGroupSharing,
saveTokenLimits,
} from "./svc";
import { apiKeyToMemberRow, memberTableColumns, PAGE_SIZE } from "./shared";
import { memberTableColumns, PAGE_SIZE } from "./shared";
import SharedGroupResources from "@/refresh-pages/admin/GroupsPage/SharedGroupResources";
import TokenLimitSection from "./TokenLimitSection";
import type { TokenLimit } from "./TokenLimitSection";
@@ -41,22 +38,7 @@ function CreateGroupPage() {
{ tokenBudget: null, periodHours: null },
]);
const { users, isLoading: usersLoading, error: usersError } = useAdminUsers();
const {
data: apiKeys,
isLoading: apiKeysLoading,
error: apiKeysError,
} = useSWR<ApiKeyDescriptor[]>("/api/admin/api-key", errorHandlingFetcher);
const isLoading = usersLoading || apiKeysLoading;
const error = usersError ?? apiKeysError;
const allRows: MemberRow[] = useMemo(() => {
const activeUsers = users.filter((u) => u.is_active);
const serviceAccountRows = (apiKeys ?? []).map(apiKeyToMemberRow);
return [...activeUsers, ...serviceAccountRows];
}, [users, apiKeys]);
const { rows: allRows, isLoading, error } = useGroupMemberCandidates();
async function handleCreate() {
const trimmed = groupName.trim();
@@ -133,11 +115,11 @@ function CreateGroupPage() {
{/* Members table */}
{isLoading && <SimpleLoader />}
{error && (
{error ? (
<Text as="p" secondaryBody text03>
Failed to load users.
</Text>
)}
) : null}
{!isLoading && !error && (
<Section

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import useSWR, { useSWRConfig } from "swr";
import useGroupMemberCandidates from "./useGroupMemberCandidates";
import { Table, Button } from "@opal/components";
import { IllustrationContent } from "@opal/layouts";
import { SvgUsers, SvgTrash, SvgMinusCircle, SvgPlusCircle } from "@opal/icons";
@@ -19,20 +20,9 @@ import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationMo
import Separator from "@/refresh-components/Separator";
import { toast } from "@/hooks/useToast";
import { errorHandlingFetcher } from "@/lib/fetcher";
import useAdminUsers from "@/hooks/useAdminUsers";
import type { UserGroup } from "@/lib/types";
import type {
ApiKeyDescriptor,
MemberRow,
TokenRateLimitDisplay,
} from "./interfaces";
import {
apiKeyToMemberRow,
baseColumns,
memberTableColumns,
tc,
PAGE_SIZE,
} from "./shared";
import type { MemberRow, TokenRateLimitDisplay } from "./interfaces";
import { baseColumns, memberTableColumns, tc, PAGE_SIZE } from "./shared";
import {
USER_GROUP_URL,
renameGroup,
@@ -104,18 +94,15 @@ function EditGroupPage({ groupId }: EditGroupPageProps) {
const initialAgentIdsRef = useRef<number[]>([]);
const initialDocSetIdsRef = useRef<number[]>([]);
// Users and API keys
const { users, isLoading: usersLoading, error: usersError } = useAdminUsers();
// Users + service accounts (curator-accessible — see hook docs).
const {
data: apiKeys,
isLoading: apiKeysLoading,
error: apiKeysError,
} = useSWR<ApiKeyDescriptor[]>("/api/admin/api-key", errorHandlingFetcher);
rows: allRows,
isLoading: candidatesLoading,
error: candidatesError,
} = useGroupMemberCandidates();
const isLoading =
groupLoading || usersLoading || apiKeysLoading || tokenLimitsLoading;
const error = groupError ?? usersError ?? apiKeysError;
const isLoading = groupLoading || candidatesLoading || tokenLimitsLoading;
const error = groupError ?? candidatesError;
// Pre-populate form when group data loads
useEffect(() => {
@@ -145,12 +132,6 @@ function EditGroupPage({ groupId }: EditGroupPageProps) {
}
}, [tokenRateLimits]);
const allRows = useMemo(() => {
const activeUsers = users.filter((u) => u.is_active);
const serviceAccountRows = (apiKeys ?? []).map(apiKeyToMemberRow);
return [...activeUsers, ...serviceAccountRows];
}, [users, apiKeys]);
const memberRows = useMemo(() => {
const selected = new Set(selectedUserIds);
return allRows.filter((r) => selected.has(r.id ?? r.email));

View File

@@ -0,0 +1,161 @@
"use client";
import { useMemo } from "react";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { useUser } from "@/providers/UserProvider";
// Curator-accessible listing of all users (and service-account entries via
// `?include_api_keys=true`). The admin-only `/manage/users/accepted/all` and
// `/manage/users/invited` endpoints 403 for global curators, which used to
// break the Edit Group page entirely — see useGroupMemberCandidates docs.
const GROUP_MEMBER_CANDIDATES_URL = "/api/manage/users?include_api_keys=true";
const ADMIN_API_KEYS_URL = "/api/admin/api-key";
import { UserStatus, type UserRole } from "@/lib/types";
// Mirrors `DANSWER_API_KEY_DUMMY_EMAIL_DOMAIN` on the backend; service-account
// users are identified by this email suffix because release/v3.1 does not yet
// expose `account_type` on the FullUserSnapshot returned from `/manage/users`.
const API_KEY_EMAIL_SUFFIX = "@onyxapikey.ai";
function isApiKeyEmail(email: string): boolean {
return email.endsWith(API_KEY_EMAIL_SUFFIX);
}
import type {
UserGroupInfo,
UserRow,
} from "@/refresh-pages/admin/UsersPage/interfaces";
import type { ApiKeyDescriptor, MemberRow } from "./interfaces";
// Backend response shape for `/api/manage/users?include_api_keys=true`. The
// existing `AllUsersResponse` in `lib/types.ts` types `accepted` as `User[]`,
// which is missing fields the table needs (`personal_name`, `account_type`,
// `groups`, etc.), so we declare an accurate local type here.
interface FullUserSnapshot {
id: string;
email: string;
role: UserRole;
is_active: boolean;
password_configured: boolean;
personal_name: string | null;
created_at: string;
updated_at: string;
groups: UserGroupInfo[];
is_scim_synced: boolean;
}
interface ManageUsersResponse {
accepted: FullUserSnapshot[];
invited: { email: string }[];
slack_users: FullUserSnapshot[];
accepted_pages: number;
invited_pages: number;
slack_users_pages: number;
}
function snapshotToMemberRow(snapshot: FullUserSnapshot): MemberRow {
return {
id: snapshot.id,
email: snapshot.email,
role: snapshot.role,
status: snapshot.is_active ? UserStatus.ACTIVE : UserStatus.INACTIVE,
is_active: snapshot.is_active,
is_scim_synced: snapshot.is_scim_synced,
personal_name: snapshot.personal_name,
created_at: snapshot.created_at,
updated_at: snapshot.updated_at,
groups: snapshot.groups,
};
}
function serviceAccountToMemberRow(
snapshot: FullUserSnapshot,
apiKey: ApiKeyDescriptor | undefined
): MemberRow {
return {
id: snapshot.id,
email: "Service Account",
role: apiKey?.api_key_role ?? snapshot.role,
status: UserStatus.ACTIVE,
is_active: true,
is_scim_synced: false,
personal_name:
apiKey?.api_key_name ?? snapshot.personal_name ?? "Unnamed Key",
created_at: null,
updated_at: null,
groups: [],
api_key_display: apiKey?.api_key_display,
};
}
interface UseGroupMemberCandidatesResult {
/** Active users + service-account rows, in the order the table expects. */
rows: MemberRow[];
/** Subset of `rows` representing real (non-service-account) users. */
userRows: MemberRow[];
isLoading: boolean;
error: unknown;
}
/**
* Returns the candidate list for the group create/edit member pickers.
*
* Hits `/api/manage/users?include_api_keys=true`, which is gated by
* `current_curator_or_admin_user` on the backend, so this works for both
* admins and global curators (the admin-only `/accepted/all` and `/invited`
* endpoints used to be called here, which 403'd for global curators and broke
* the Edit Group page entirely).
*
* For admins, we additionally fetch `/admin/api-key` to enrich service-account
* rows with the masked api-key display string. That call is admin-only and is
* skipped for curators; its failure is non-fatal.
*/
export default function useGroupMemberCandidates(): UseGroupMemberCandidatesResult {
const { isAdmin } = useUser();
const {
data: usersData,
isLoading: usersLoading,
error: usersError,
} = useSWR<ManageUsersResponse>(
GROUP_MEMBER_CANDIDATES_URL,
errorHandlingFetcher
);
const { data: apiKeys, isLoading: apiKeysLoading } = useSWR<
ApiKeyDescriptor[]
>(isAdmin ? ADMIN_API_KEYS_URL : null, errorHandlingFetcher);
const apiKeysByUserId = useMemo(() => {
const map = new Map<string, ApiKeyDescriptor>();
for (const key of apiKeys ?? []) map.set(key.user_id, key);
return map;
}, [apiKeys]);
const { rows, userRows } = useMemo(() => {
const accepted = usersData?.accepted ?? [];
const userRowsLocal: MemberRow[] = [];
const serviceAccountRows: MemberRow[] = [];
for (const snapshot of accepted) {
if (!snapshot.is_active) continue;
if (isApiKeyEmail(snapshot.email)) {
serviceAccountRows.push(
serviceAccountToMemberRow(snapshot, apiKeysByUserId.get(snapshot.id))
);
} else {
userRowsLocal.push(snapshotToMemberRow(snapshot));
}
}
return {
rows: [...userRowsLocal, ...serviceAccountRows],
userRows: userRowsLocal,
};
}, [usersData, apiKeysByUserId]);
return {
rows,
userRows,
isLoading: usersLoading || (isAdmin && apiKeysLoading),
error: usersError,
};
}