mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-16 21:22:41 +00:00
Compare commits
3 Commits
nikg/admin
...
bo/hook_ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5639ff4cc8 | ||
|
|
5f628da4e8 | ||
|
|
e40f80cfe1 |
@@ -118,9 +118,7 @@ JWT_PUBLIC_KEY_URL: str | None = os.getenv("JWT_PUBLIC_KEY_URL", None)
|
||||
SUPER_USERS = json.loads(os.environ.get("SUPER_USERS", "[]"))
|
||||
SUPER_CLOUD_API_KEY = os.environ.get("SUPER_CLOUD_API_KEY", "api_key")
|
||||
|
||||
# The posthog client does not accept empty API keys or hosts however it fails silently
|
||||
# when the capture is called. These defaults prevent Posthog issues from breaking the Onyx app
|
||||
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY") or "FooBar"
|
||||
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY")
|
||||
POSTHOG_HOST = os.environ.get("POSTHOG_HOST") or "https://us.i.posthog.com"
|
||||
POSTHOG_DEBUG_LOGS_ENABLED = (
|
||||
os.environ.get("POSTHOG_DEBUG_LOGS_ENABLED", "").lower() == "true"
|
||||
|
||||
@@ -34,6 +34,9 @@ class PostHogFeatureFlagProvider(FeatureFlagProvider):
|
||||
Returns:
|
||||
True if the feature is enabled for the user, False otherwise.
|
||||
"""
|
||||
if not posthog:
|
||||
return False
|
||||
|
||||
try:
|
||||
posthog.set(
|
||||
distinct_id=user_id,
|
||||
|
||||
@@ -9,6 +9,7 @@ from ee.onyx.configs.app_configs import POSTHOG_API_KEY
|
||||
from ee.onyx.configs.app_configs import POSTHOG_DEBUG_LOGS_ENABLED
|
||||
from ee.onyx.configs.app_configs import POSTHOG_HOST
|
||||
from onyx.utils.logger import setup_logger
|
||||
from shared_configs.configs import MULTI_TENANT
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
@@ -18,12 +19,19 @@ def posthog_on_error(error: Any, items: Any) -> None:
|
||||
logger.error(f"PostHog error: {error}, items: {items}")
|
||||
|
||||
|
||||
posthog = Posthog(
|
||||
project_api_key=POSTHOG_API_KEY,
|
||||
host=POSTHOG_HOST,
|
||||
debug=POSTHOG_DEBUG_LOGS_ENABLED,
|
||||
on_error=posthog_on_error,
|
||||
)
|
||||
posthog: Posthog | None = None
|
||||
if POSTHOG_API_KEY:
|
||||
posthog = Posthog(
|
||||
project_api_key=POSTHOG_API_KEY,
|
||||
host=POSTHOG_HOST,
|
||||
debug=POSTHOG_DEBUG_LOGS_ENABLED,
|
||||
on_error=posthog_on_error,
|
||||
)
|
||||
elif MULTI_TENANT:
|
||||
logger.warning(
|
||||
"POSTHOG_API_KEY is not set but MULTI_TENANT is enabled — "
|
||||
"PostHog telemetry and feature flags will be disabled"
|
||||
)
|
||||
|
||||
# For cross referencing between cloud and www Onyx sites
|
||||
# NOTE: These clients are separate because they are separate posthog projects.
|
||||
@@ -60,7 +68,7 @@ def capture_and_sync_with_alternate_posthog(
|
||||
logger.error(f"Error capturing marketing posthog event: {e}")
|
||||
|
||||
try:
|
||||
if cloud_user_id := props.get("onyx_cloud_user_id"):
|
||||
if posthog and (cloud_user_id := props.get("onyx_cloud_user_id")):
|
||||
cloud_props = props.copy()
|
||||
cloud_props.pop("onyx_cloud_user_id", None)
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ def event_telemetry(
|
||||
distinct_id: str, event: str, properties: dict | None = None
|
||||
) -> None:
|
||||
"""Capture and send an event to PostHog, flushing immediately."""
|
||||
if not posthog:
|
||||
return
|
||||
|
||||
logger.info(f"Capturing PostHog event: {distinct_id} {event} {properties}")
|
||||
try:
|
||||
posthog.capture(distinct_id, event, properties)
|
||||
|
||||
0
backend/onyx/hooks/__init__.py
Normal file
0
backend/onyx/hooks/__init__.py
Normal file
88
backend/onyx/hooks/models.py
Normal file
88
backend/onyx/hooks/models.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from onyx.db.enums import HookFailStrategy
|
||||
from onyx.db.enums import HookPoint
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HookCreateRequest(BaseModel):
|
||||
name: str
|
||||
hook_point: HookPoint
|
||||
endpoint_url: str
|
||||
api_key: str | None = None
|
||||
fail_strategy: HookFailStrategy = HookFailStrategy.HARD
|
||||
timeout_seconds: float | None = None # if None, uses HookPointSpec default
|
||||
|
||||
|
||||
class HookUpdateRequest(BaseModel):
|
||||
name: str | None = None
|
||||
endpoint_url: str | None = None
|
||||
api_key: str | None = None
|
||||
fail_strategy: HookFailStrategy | None = None
|
||||
timeout_seconds: float | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HookPointMetaResponse(BaseModel):
|
||||
hook_point: HookPoint
|
||||
display_name: str
|
||||
description: str
|
||||
docs_url: str | None
|
||||
input_schema: dict[str, Any]
|
||||
output_schema: dict[str, Any]
|
||||
default_timeout_seconds: float
|
||||
default_fail_strategy: HookFailStrategy
|
||||
fail_hard_description: str
|
||||
|
||||
|
||||
class HookResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
hook_point: HookPoint
|
||||
endpoint_url: str | None
|
||||
fail_strategy: HookFailStrategy
|
||||
timeout_seconds: float
|
||||
is_active: bool
|
||||
creator_email: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class HookValidateResponse(BaseModel):
|
||||
success: bool
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HookHealthStatus(str, Enum):
|
||||
healthy = "healthy" # green — reachable, no failures in last 1h
|
||||
degraded = "degraded" # yellow — reachable, failures in last 1h
|
||||
unreachable = "unreachable" # red — is_reachable=false or null
|
||||
|
||||
|
||||
class HookFailureRecord(BaseModel):
|
||||
error_message: str | None
|
||||
status_code: int | None
|
||||
duration_ms: int | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class HookHealthResponse(BaseModel):
|
||||
status: HookHealthStatus
|
||||
recent_failures: list[HookFailureRecord] # last 10, newest first
|
||||
0
backend/onyx/hooks/points/__init__.py
Normal file
0
backend/onyx/hooks/points/__init__.py
Normal file
38
backend/onyx/hooks/points/base.py
Normal file
38
backend/onyx/hooks/points/base.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from abc import ABC
|
||||
from abc import abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
from typing import Any
|
||||
|
||||
from onyx.db.enums import HookFailStrategy
|
||||
from onyx.db.enums import HookPoint
|
||||
|
||||
|
||||
@dataclass
|
||||
class HookPointSpec(ABC):
|
||||
"""Static metadata and contract for a pipeline hook point.
|
||||
|
||||
Each hook point is a concrete subclass of this class. Onyx engineers
|
||||
own these definitions — customers never touch this code.
|
||||
|
||||
Instances are registered in onyx.hooks.registry.REGISTRY and served
|
||||
via GET /api/admin/hook-points.
|
||||
"""
|
||||
|
||||
hook_point: HookPoint
|
||||
display_name: str
|
||||
description: str
|
||||
default_timeout_seconds: float
|
||||
fail_hard_description: str
|
||||
docs_url: str | None = None
|
||||
default_fail_strategy: HookFailStrategy = field(default=HookFailStrategy.HARD)
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def input_schema(self) -> dict[str, Any]:
|
||||
"""JSON schema describing the request payload sent to the customer's endpoint."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def output_schema(self) -> dict[str, Any]:
|
||||
"""JSON schema describing the expected response from the customer's endpoint."""
|
||||
79
backend/onyx/hooks/points/query_processing.py
Normal file
79
backend/onyx/hooks/points/query_processing.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from onyx.db.enums import HookPoint
|
||||
from onyx.hooks.points.base import HookPointSpec
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryProcessingSpec(HookPointSpec):
|
||||
"""Hook point that runs on every user query before it enters the pipeline.
|
||||
|
||||
Call site: inside handle_stream_message_objects() in
|
||||
backend/onyx/chat/process_message.py, immediately after message_text is
|
||||
assigned from the request and before create_new_chat_message() saves it.
|
||||
|
||||
This is the earliest possible point in the query pipeline:
|
||||
- Raw query — unmodified, exactly as the user typed it
|
||||
- No side effects yet — message has not been saved to DB
|
||||
- User identity is available for user-specific logic
|
||||
|
||||
Supported use cases:
|
||||
- Query rejection: block queries based on content or user context
|
||||
- Query rewriting: normalize, expand, or modify the query
|
||||
- PII removal: scrub sensitive data before the LLM sees it
|
||||
- Access control: reject queries from certain users or groups
|
||||
- Query auditing: log or track queries based on business rules
|
||||
"""
|
||||
|
||||
hook_point: HookPoint = HookPoint.QUERY_PROCESSING
|
||||
display_name: str = "Query Processing"
|
||||
description: str = (
|
||||
"Runs on every user query before it enters the pipeline. "
|
||||
"Allows rewriting, filtering, or rejecting queries."
|
||||
)
|
||||
default_timeout_seconds: float = 5.0 # user is actively waiting — keep tight
|
||||
fail_hard_description: str = (
|
||||
"The query will be blocked and the user will see an error message."
|
||||
)
|
||||
docs_url: str | None = None
|
||||
|
||||
@property
|
||||
def input_schema(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The raw query string exactly as the user typed it.",
|
||||
},
|
||||
"user_email": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Email of the user submitting the query, or null if unauthenticated.",
|
||||
},
|
||||
},
|
||||
"required": ["query", "user_email"],
|
||||
}
|
||||
|
||||
@property
|
||||
def output_schema(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": ["string", "null"],
|
||||
"description": (
|
||||
"The (optionally modified) query to use. "
|
||||
"Set to null to reject the query."
|
||||
),
|
||||
},
|
||||
"rejection_message": {
|
||||
"type": ["string", "null"],
|
||||
"description": (
|
||||
"Message shown to the user when query is null. "
|
||||
"Falls back to a generic message if not provided."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
34
backend/onyx/hooks/registry.py
Normal file
34
backend/onyx/hooks/registry.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from onyx.db.enums import HookPoint
|
||||
from onyx.hooks.points.base import HookPointSpec
|
||||
from onyx.hooks.points.query_processing import QueryProcessingSpec
|
||||
|
||||
REGISTRY: dict[HookPoint, HookPointSpec] = {
|
||||
HookPoint.QUERY_PROCESSING: QueryProcessingSpec(),
|
||||
}
|
||||
|
||||
_missing = set(HookPoint) - set(REGISTRY)
|
||||
if _missing:
|
||||
raise RuntimeError(
|
||||
f"Hook point(s) have no registered spec: {_missing}. "
|
||||
"Add an entry to onyx.hooks.registry.REGISTRY."
|
||||
)
|
||||
|
||||
|
||||
def get_hook_point_spec(hook_point: HookPoint) -> HookPointSpec:
|
||||
"""Returns the spec for a given hook point.
|
||||
|
||||
Raises ValueError if the hook point has no registered spec — this is a
|
||||
programmer error; every HookPoint enum value must have a corresponding spec
|
||||
in REGISTRY.
|
||||
"""
|
||||
try:
|
||||
return REGISTRY[hook_point]
|
||||
except KeyError:
|
||||
raise ValueError(
|
||||
f"No spec registered for hook point {hook_point!r}. "
|
||||
"Add an entry to onyx.hooks.registry.REGISTRY."
|
||||
)
|
||||
|
||||
|
||||
def get_all_specs() -> list[HookPointSpec]:
|
||||
return list(REGISTRY.values())
|
||||
@@ -65,7 +65,7 @@ attrs==25.4.0
|
||||
# jsonschema
|
||||
# referencing
|
||||
# zeep
|
||||
authlib==1.6.7
|
||||
authlib==1.6.9
|
||||
# via fastmcp
|
||||
azure-cognitiveservices-speech==1.38.0
|
||||
# via onyx
|
||||
|
||||
0
backend/tests/unit/onyx/hooks/__init__.py
Normal file
0
backend/tests/unit/onyx/hooks/__init__.py
Normal file
50
backend/tests/unit/onyx/hooks/test_query_processing_spec.py
Normal file
50
backend/tests/unit/onyx/hooks/test_query_processing_spec.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from onyx.db.enums import HookFailStrategy
|
||||
from onyx.db.enums import HookPoint
|
||||
from onyx.hooks.points.query_processing import QueryProcessingSpec
|
||||
|
||||
|
||||
def test_hook_point_is_query_processing() -> None:
|
||||
assert QueryProcessingSpec().hook_point == HookPoint.QUERY_PROCESSING
|
||||
|
||||
|
||||
def test_default_fail_strategy_is_hard() -> None:
|
||||
assert QueryProcessingSpec().default_fail_strategy == HookFailStrategy.HARD
|
||||
|
||||
|
||||
def test_default_timeout_seconds() -> None:
|
||||
# User is actively waiting — 5s is the documented contract for this hook point
|
||||
assert QueryProcessingSpec().default_timeout_seconds == 5.0
|
||||
|
||||
|
||||
def test_input_schema_required_fields() -> None:
|
||||
schema = QueryProcessingSpec().input_schema
|
||||
assert schema["type"] == "object"
|
||||
required = schema["required"]
|
||||
assert "query" in required
|
||||
assert "user_email" in required
|
||||
|
||||
|
||||
def test_input_schema_query_is_string() -> None:
|
||||
props = QueryProcessingSpec().input_schema["properties"]
|
||||
assert props["query"]["type"] == "string"
|
||||
|
||||
|
||||
def test_input_schema_user_email_is_nullable() -> None:
|
||||
props = QueryProcessingSpec().input_schema["properties"]
|
||||
assert "null" in props["user_email"]["type"]
|
||||
|
||||
|
||||
def test_output_schema_query_is_required() -> None:
|
||||
schema = QueryProcessingSpec().output_schema
|
||||
assert "query" in schema["required"]
|
||||
|
||||
|
||||
def test_output_schema_query_is_nullable() -> None:
|
||||
# null means "reject the query"
|
||||
props = QueryProcessingSpec().output_schema["properties"]
|
||||
assert "null" in props["query"]["type"]
|
||||
|
||||
|
||||
def test_output_schema_rejection_message_is_optional() -> None:
|
||||
schema = QueryProcessingSpec().output_schema
|
||||
assert "rejection_message" not in schema.get("required", [])
|
||||
35
backend/tests/unit/onyx/hooks/test_registry.py
Normal file
35
backend/tests/unit/onyx/hooks/test_registry.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import pytest
|
||||
|
||||
from onyx.db.enums import HookPoint
|
||||
from onyx.hooks import registry as registry_module
|
||||
from onyx.hooks.registry import get_all_specs
|
||||
from onyx.hooks.registry import get_hook_point_spec
|
||||
from onyx.hooks.registry import REGISTRY
|
||||
|
||||
|
||||
def test_registry_covers_all_hook_points() -> None:
|
||||
"""Every HookPoint enum member must have a registered spec."""
|
||||
assert set(REGISTRY.keys()) == set(
|
||||
HookPoint
|
||||
), f"Missing specs for: {set(HookPoint) - set(REGISTRY.keys())}"
|
||||
|
||||
|
||||
def test_get_hook_point_spec_returns_correct_spec() -> None:
|
||||
for hook_point in HookPoint:
|
||||
spec = get_hook_point_spec(hook_point)
|
||||
assert spec.hook_point == hook_point
|
||||
|
||||
|
||||
def test_get_all_specs_returns_all() -> None:
|
||||
specs = get_all_specs()
|
||||
assert len(specs) == len(HookPoint)
|
||||
assert {s.hook_point for s in specs} == set(HookPoint)
|
||||
|
||||
|
||||
def test_get_hook_point_spec_raises_for_unregistered(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""get_hook_point_spec raises ValueError when a hook point has no spec."""
|
||||
monkeypatch.setattr(registry_module, "REGISTRY", {})
|
||||
with pytest.raises(ValueError, match="No spec registered for hook point"):
|
||||
get_hook_point_spec(HookPoint.QUERY_PROCESSING)
|
||||
6
uv.lock
generated
6
uv.lock
generated
@@ -453,14 +453,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "authlib"
|
||||
version = "1.6.7"
|
||||
version = "1.6.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/dc/ed1681bf1339dd6ea1ce56136bad4baabc6f7ad466e375810702b0237047/authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b", size = 164950, upload-time = "2026-02-06T14:04:14.171Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/00/3ed12264094ec91f534fae429945efbaa9f8c666f3aa7061cc3b2a26a0cd/authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0", size = 244115, upload-time = "2026-02-06T14:04:12.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgUserPlus, SvgUserX, SvgXCircle, SvgKey } from "@opal/icons";
|
||||
import { Disabled } from "@opal/core";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import {
|
||||
deactivateUser,
|
||||
activateUser,
|
||||
deleteUser,
|
||||
cancelInvite,
|
||||
resetPassword,
|
||||
} from "./svc";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runAction(
|
||||
action: () => Promise<void>,
|
||||
successMessage: string,
|
||||
onDone: () => void,
|
||||
setIsSubmitting: (v: boolean) => void
|
||||
) {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await action();
|
||||
onDone();
|
||||
toast.success(successMessage);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cancel Invite Modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CancelInviteModalProps {
|
||||
email: string;
|
||||
onClose: () => void;
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
export function CancelInviteModal({
|
||||
email,
|
||||
onClose,
|
||||
onMutate,
|
||||
}: CancelInviteModalProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
return (
|
||||
<ConfirmationModalLayout
|
||||
icon={(props) => (
|
||||
<SvgUserX {...props} className="text-action-danger-05" />
|
||||
)}
|
||||
title="Cancel Invite"
|
||||
onClose={isSubmitting ? undefined : onClose}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() =>
|
||||
runAction(
|
||||
() => cancelInvite(email),
|
||||
"Invite cancelled",
|
||||
() => {
|
||||
onMutate();
|
||||
onClose();
|
||||
},
|
||||
setIsSubmitting
|
||||
)
|
||||
}
|
||||
>
|
||||
Cancel Invite
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
<Text as="span" text05>
|
||||
{email}
|
||||
</Text>{" "}
|
||||
will no longer be able to join Onyx with this invite.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deactivate User Modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DeactivateUserModalProps {
|
||||
email: string;
|
||||
onClose: () => void;
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
export function DeactivateUserModal({
|
||||
email,
|
||||
onClose,
|
||||
onMutate,
|
||||
}: DeactivateUserModalProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
return (
|
||||
<ConfirmationModalLayout
|
||||
icon={(props) => (
|
||||
<SvgUserX {...props} className="text-action-danger-05" />
|
||||
)}
|
||||
title="Deactivate User"
|
||||
onClose={isSubmitting ? undefined : onClose}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() =>
|
||||
runAction(
|
||||
() => deactivateUser(email),
|
||||
"User deactivated",
|
||||
() => {
|
||||
onMutate();
|
||||
onClose();
|
||||
},
|
||||
setIsSubmitting
|
||||
)
|
||||
}
|
||||
>
|
||||
Deactivate
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
<Text as="span" text05>
|
||||
{email}
|
||||
</Text>{" "}
|
||||
will immediately lose access to Onyx. Their sessions and agents will be
|
||||
preserved. Their license seat will be freed. You can reactivate this
|
||||
account later.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Activate User Modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ActivateUserModalProps {
|
||||
email: string;
|
||||
onClose: () => void;
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
export function ActivateUserModal({
|
||||
email,
|
||||
onClose,
|
||||
onMutate,
|
||||
}: ActivateUserModalProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
return (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgUserPlus}
|
||||
title="Activate User"
|
||||
onClose={isSubmitting ? undefined : onClose}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
onClick={() =>
|
||||
runAction(
|
||||
() => activateUser(email),
|
||||
"User activated",
|
||||
() => {
|
||||
onMutate();
|
||||
onClose();
|
||||
},
|
||||
setIsSubmitting
|
||||
)
|
||||
}
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
<Text as="span" text05>
|
||||
{email}
|
||||
</Text>{" "}
|
||||
will regain access to Onyx.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete User Modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DeleteUserModalProps {
|
||||
email: string;
|
||||
onClose: () => void;
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
export function DeleteUserModal({
|
||||
email,
|
||||
onClose,
|
||||
onMutate,
|
||||
}: DeleteUserModalProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
return (
|
||||
<ConfirmationModalLayout
|
||||
icon={(props) => (
|
||||
<SvgUserX {...props} className="text-action-danger-05" />
|
||||
)}
|
||||
title="Delete User"
|
||||
onClose={isSubmitting ? undefined : onClose}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() =>
|
||||
runAction(
|
||||
() => deleteUser(email),
|
||||
"User deleted",
|
||||
() => {
|
||||
onMutate();
|
||||
onClose();
|
||||
},
|
||||
setIsSubmitting
|
||||
)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
<Text as="span" text05>
|
||||
{email}
|
||||
</Text>{" "}
|
||||
will be permanently removed from Onyx. All of their session history will
|
||||
be deleted. Deletion cannot be undone.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reset Password Modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ResetPasswordModalProps {
|
||||
email: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ResetPasswordModal({
|
||||
email,
|
||||
onClose,
|
||||
}: ResetPasswordModalProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [newPassword, setNewPassword] = useState<string | null>(null);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setNewPassword(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgKey}
|
||||
title={newPassword ? "Password Reset" : "Reset Password"}
|
||||
onClose={isSubmitting ? undefined : handleClose}
|
||||
submit={
|
||||
newPassword ? (
|
||||
<Button onClick={handleClose}>Done</Button>
|
||||
) : (
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await resetPassword(email);
|
||||
setNewPassword(result.new_password);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to reset password"
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
</Disabled>
|
||||
)
|
||||
}
|
||||
>
|
||||
{newPassword ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text as="p" text03>
|
||||
The password for{" "}
|
||||
<Text as="span" text05>
|
||||
{email}
|
||||
</Text>{" "}
|
||||
has been reset. Copy the new password below — it will not be shown
|
||||
again.
|
||||
</Text>
|
||||
<code className="rounded-sm bg-background-neutral-02 px-3 py-2 text-sm select-all">
|
||||
{newPassword}
|
||||
</code>
|
||||
</div>
|
||||
) : (
|
||||
<Text as="p" text03>
|
||||
This will generate a new random password for{" "}
|
||||
<Text as="span" text05>
|
||||
{email}
|
||||
</Text>
|
||||
. Their current password will stop working immediately.
|
||||
</Text>
|
||||
)}
|
||||
</ConfirmationModalLayout>
|
||||
);
|
||||
}
|
||||
@@ -166,7 +166,7 @@ export default function UserFilters({
|
||||
<Popover>
|
||||
<Popover.Trigger asChild>
|
||||
<FilterButton
|
||||
aria-label="Filter by role"
|
||||
data-testid="filter-role"
|
||||
leftIcon={SvgUsers}
|
||||
active={hasRoleFilter}
|
||||
onClear={() => onRolesChange([])}
|
||||
@@ -214,7 +214,7 @@ export default function UserFilters({
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<FilterButton
|
||||
aria-label="Filter by group"
|
||||
data-testid="filter-group"
|
||||
leftIcon={SvgUsers}
|
||||
active={hasGroupFilter}
|
||||
onClear={() => onGroupsChange([])}
|
||||
@@ -269,7 +269,7 @@ export default function UserFilters({
|
||||
<Popover>
|
||||
<Popover.Trigger asChild>
|
||||
<FilterButton
|
||||
aria-label="Filter by status"
|
||||
data-testid="filter-status"
|
||||
leftIcon={SvgUsers}
|
||||
active={hasStatusFilter}
|
||||
onClear={() => onStatusesChange([])}
|
||||
|
||||
@@ -14,19 +14,20 @@ import {
|
||||
import { Disabled } from "@opal/core";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { UserStatus } from "@/lib/types";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { approveRequest } from "./svc";
|
||||
import EditUserModal from "./EditUserModal";
|
||||
import {
|
||||
CancelInviteModal,
|
||||
DeactivateUserModal,
|
||||
ActivateUserModal,
|
||||
DeleteUserModal,
|
||||
ResetPasswordModal,
|
||||
} from "./UserActionModals";
|
||||
deactivateUser,
|
||||
activateUser,
|
||||
deleteUser,
|
||||
cancelInvite,
|
||||
approveRequest,
|
||||
resetPassword,
|
||||
} from "./svc";
|
||||
import EditUserModal from "./EditUserModal";
|
||||
import type { UserRow } from "./interfaces";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -57,19 +58,31 @@ export default function UserRowActions({
|
||||
}: UserRowActionsProps) {
|
||||
const [modal, setModal] = useState<Modal | null>(null);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [newPassword, setNewPassword] = useState<string | null>(null);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const openModal = (type: Modal) => {
|
||||
setPopoverOpen(false);
|
||||
setModal(type);
|
||||
};
|
||||
|
||||
const closeModal = () => setModal(null);
|
||||
|
||||
const closeAndMutate = () => {
|
||||
setModal(null);
|
||||
onMutate();
|
||||
};
|
||||
|
||||
// Status-aware action menus
|
||||
const actionButtons = (() => {
|
||||
// SCIM-managed users get limited actions — most changes would be
|
||||
@@ -119,17 +132,10 @@ export default function UserRowActions({
|
||||
icon={SvgUserCheck}
|
||||
onClick={() => {
|
||||
setPopoverOpen(false);
|
||||
void (async () => {
|
||||
try {
|
||||
await approveRequest(user.email);
|
||||
onMutate();
|
||||
toast.success("Request approved");
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "An error occurred"
|
||||
);
|
||||
}
|
||||
})();
|
||||
handleAction(
|
||||
() => approveRequest(user.email),
|
||||
"Request approved"
|
||||
);
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
@@ -170,23 +176,6 @@ export default function UserRowActions({
|
||||
case UserStatus.INACTIVE:
|
||||
return (
|
||||
<>
|
||||
{user.id && (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgUsers}
|
||||
onClick={() => openModal(Modal.EDIT_GROUPS)}
|
||||
>
|
||||
Groups & Roles
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgKey}
|
||||
onClick={() => openModal(Modal.RESET_PASSWORD)}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
<Separator paddingXRem={0.5} />
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgUserPlus}
|
||||
@@ -234,45 +223,211 @@ export default function UserRowActions({
|
||||
{modal === Modal.EDIT_GROUPS && user.id && (
|
||||
<EditUserModal
|
||||
user={user as UserRow & { id: string }}
|
||||
onClose={closeModal}
|
||||
onClose={() => setModal(null)}
|
||||
onMutate={onMutate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modal === Modal.CANCEL_INVITE && (
|
||||
<CancelInviteModal
|
||||
email={user.email}
|
||||
onClose={closeModal}
|
||||
onMutate={onMutate}
|
||||
/>
|
||||
<ConfirmationModalLayout
|
||||
icon={(props) => (
|
||||
<SvgUserX {...props} className="text-action-danger-05" />
|
||||
)}
|
||||
title="Cancel Invite"
|
||||
onClose={isSubmitting ? undefined : () => setModal(null)}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
handleAction(
|
||||
() => cancelInvite(user.email),
|
||||
"Invite cancelled"
|
||||
);
|
||||
}}
|
||||
>
|
||||
Cancel Invite
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
<Text as="span" text05>
|
||||
{user.email}
|
||||
</Text>{" "}
|
||||
will no longer be able to join Onyx with this invite.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
{modal === Modal.DEACTIVATE && (
|
||||
<DeactivateUserModal
|
||||
email={user.email}
|
||||
onClose={closeModal}
|
||||
onMutate={onMutate}
|
||||
/>
|
||||
<ConfirmationModalLayout
|
||||
icon={(props) => (
|
||||
<SvgUserX {...props} className="text-action-danger-05" />
|
||||
)}
|
||||
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. Their license seat will be freed. You can reactivate
|
||||
this account later.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
{modal === Modal.ACTIVATE && (
|
||||
<ActivateUserModal
|
||||
email={user.email}
|
||||
onClose={closeModal}
|
||||
onMutate={onMutate}
|
||||
/>
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgUserPlus}
|
||||
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 === Modal.DELETE && (
|
||||
<DeleteUserModal
|
||||
email={user.email}
|
||||
onClose={closeModal}
|
||||
onMutate={onMutate}
|
||||
/>
|
||||
<ConfirmationModalLayout
|
||||
icon={(props) => (
|
||||
<SvgUserX {...props} className="text-action-danger-05" />
|
||||
)}
|
||||
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. Deletion cannot be undone.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
{modal === Modal.RESET_PASSWORD && (
|
||||
<ResetPasswordModal email={user.email} onClose={closeModal} />
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgKey}
|
||||
title={newPassword ? "Password Reset" : "Reset Password"}
|
||||
onClose={
|
||||
isSubmitting
|
||||
? undefined
|
||||
: () => {
|
||||
setModal(null);
|
||||
setNewPassword(null);
|
||||
}
|
||||
}
|
||||
submit={
|
||||
newPassword ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setModal(null);
|
||||
setNewPassword(null);
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
) : (
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await resetPassword(user.email);
|
||||
setNewPassword(result.new_password);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to reset password"
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
</Disabled>
|
||||
)
|
||||
}
|
||||
>
|
||||
{newPassword ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text as="p" text03>
|
||||
The password for{" "}
|
||||
<Text as="span" text05>
|
||||
{user.email}
|
||||
</Text>{" "}
|
||||
has been reset. Copy the new password below — it will not be
|
||||
shown again.
|
||||
</Text>
|
||||
<code className="rounded-sm bg-background-neutral-02 px-3 py-2 text-sm select-all">
|
||||
{newPassword}
|
||||
</code>
|
||||
</div>
|
||||
) : (
|
||||
<Text as="p" text03>
|
||||
This will generate a new random password for{" "}
|
||||
<Text as="span" text05>
|
||||
{user.email}
|
||||
</Text>
|
||||
. Their current password will stop working immediately.
|
||||
</Text>
|
||||
)}
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
/**
|
||||
* Page Object Model for the Admin Users page (/admin/users).
|
||||
*
|
||||
* Encapsulates all locators and interactions so specs remain declarative.
|
||||
*/
|
||||
|
||||
import { type Page, type Locator, expect } from "@playwright/test";
|
||||
|
||||
/** URL pattern that matches the users data fetch. */
|
||||
const USERS_API = /\/api\/manage\/users\/(accepted\/all|invited)/;
|
||||
|
||||
export class UsersAdminPage {
|
||||
readonly page: Page;
|
||||
|
||||
// Top-level elements
|
||||
readonly inviteButton: Locator;
|
||||
readonly searchInput: Locator;
|
||||
|
||||
// Filter buttons
|
||||
readonly accountTypesFilter: Locator;
|
||||
readonly groupsFilter: Locator;
|
||||
readonly statusFilter: Locator;
|
||||
|
||||
// Table
|
||||
readonly table: Locator;
|
||||
readonly tableRows: Locator;
|
||||
|
||||
// Pagination & footer
|
||||
readonly paginationSummary: Locator;
|
||||
readonly downloadCsvButton: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.inviteButton = page.getByRole("button", { name: "Invite Users" });
|
||||
this.searchInput = page.getByPlaceholder("Search users...");
|
||||
|
||||
this.accountTypesFilter = page.getByLabel("Filter by role");
|
||||
this.groupsFilter = page.getByLabel("Filter by group");
|
||||
this.statusFilter = page.getByLabel("Filter by status");
|
||||
|
||||
this.table = page.getByRole("table");
|
||||
this.tableRows = page.getByRole("table").locator("tbody tr");
|
||||
|
||||
this.paginationSummary = page.getByText(/Showing \d/);
|
||||
this.downloadCsvButton = page.getByRole("button", {
|
||||
name: "Download CSV",
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Popover helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a locator for the currently open popover / filter dropdown.
|
||||
* Radix Popover renders its content with `role="dialog"`. Using
|
||||
* `getByRole("dialog").first()` targets the oldest open dialog, which is
|
||||
* always the popover during row-action or filter flows (confirmation
|
||||
* modals open later and would be `.last()`).
|
||||
*/
|
||||
get popover(): Locator {
|
||||
return this.page.getByRole("dialog").first();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async goto() {
|
||||
await this.page.goto("/admin/users");
|
||||
await expect(this.page.getByText("Users & Requests")).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
// Wait for the table to finish loading (pagination summary only appears
|
||||
// after the async data fetch completes).
|
||||
await expect(this.paginationSummary).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Waiting helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Wait for the users API response that follows a table-refreshing action. */
|
||||
private async waitForTableRefresh(): Promise<void> {
|
||||
await this.page.waitForResponse(USERS_API);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async search(term: string) {
|
||||
await this.searchInput.fill(term);
|
||||
}
|
||||
|
||||
async clearSearch() {
|
||||
await this.searchInput.fill("");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async openAccountTypesFilter() {
|
||||
await this.accountTypesFilter.click();
|
||||
await expect(this.popover).toBeVisible();
|
||||
}
|
||||
|
||||
async selectAccountType(label: string) {
|
||||
await this.popover.getByText(label, { exact: false }).first().click();
|
||||
}
|
||||
|
||||
async openStatusFilter() {
|
||||
await this.statusFilter.click();
|
||||
await expect(this.popover).toBeVisible();
|
||||
}
|
||||
|
||||
async selectStatus(label: string) {
|
||||
await this.popover.getByText(label, { exact: false }).first().click();
|
||||
}
|
||||
|
||||
async openGroupsFilter() {
|
||||
await this.groupsFilter.click();
|
||||
await expect(this.popover).toBeVisible();
|
||||
}
|
||||
|
||||
async selectGroup(label: string) {
|
||||
await this.popover.getByText(label, { exact: false }).first().click();
|
||||
}
|
||||
|
||||
async closePopover() {
|
||||
await this.page.keyboard.press("Escape");
|
||||
await expect(this.page.getByRole("dialog")).not.toBeVisible();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table interactions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getVisibleRowCount(): Promise<number> {
|
||||
return await this.tableRows.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the text content of a specific column across all visible rows.
|
||||
* Column indices: 0=Name, 1=Groups, 2=Account Type, 3=Status, 4=Last Updated.
|
||||
*/
|
||||
async getColumnTexts(columnIndex: number): Promise<string[]> {
|
||||
const cells = this.tableRows.locator(`td:nth-child(${columnIndex + 2})`);
|
||||
const count = await cells.count();
|
||||
const texts: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = await cells.nth(i).textContent();
|
||||
if (text) texts.push(text.trim());
|
||||
}
|
||||
return texts;
|
||||
}
|
||||
|
||||
getRowByEmail(email: string): Locator {
|
||||
return this.table.getByRole("row").filter({ hasText: email });
|
||||
}
|
||||
|
||||
/** Click the sort button on a column header. */
|
||||
async sortByColumn(columnName: string) {
|
||||
// Column headers are <th> elements. The sort button is a child <button>
|
||||
// that only appears on hover — hover first to reveal it.
|
||||
const header = this.table.locator("th").filter({ hasText: columnName });
|
||||
await header.hover();
|
||||
await header.locator("button").first().click();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Click a numbered page button in the table footer. */
|
||||
async goToPage(pageNumber: number) {
|
||||
const footer = this.page.locator(".table-footer");
|
||||
await footer
|
||||
.getByRole("button")
|
||||
.filter({ hasText: String(pageNumber) })
|
||||
.click();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async openRowActions(email: string) {
|
||||
const row = this.getRowByEmail(email);
|
||||
const actionsButton = row.getByRole("button").last();
|
||||
await actionsButton.click();
|
||||
await expect(this.popover).toBeVisible();
|
||||
}
|
||||
|
||||
async clickRowAction(actionName: string) {
|
||||
await this.popover.getByText(actionName).first().click();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Confirmation modals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the most recently opened dialog (modal).
|
||||
* Uses `.last()` because confirmation modals are portaled after row-action
|
||||
* popovers, and a closing popover (role="dialog") may briefly remain in the
|
||||
* DOM during its exit animation.
|
||||
*/
|
||||
get dialog(): Locator {
|
||||
return this.page.getByRole("dialog").last();
|
||||
}
|
||||
|
||||
async confirmModalAction(buttonName: string) {
|
||||
await this.dialog.getByRole("button", { name: buttonName }).first().click();
|
||||
}
|
||||
|
||||
async cancelModal() {
|
||||
await this.dialog.getByRole("button", { name: "Cancel" }).first().click();
|
||||
}
|
||||
|
||||
async expectToast(message: string | RegExp) {
|
||||
await expect(this.page.getByText(message)).toBeVisible();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invite modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The email input inside the invite modal. */
|
||||
get inviteEmailInput(): Locator {
|
||||
return this.dialog.getByPlaceholder("Add an email and press enter");
|
||||
}
|
||||
|
||||
async openInviteModal() {
|
||||
await this.inviteButton.click();
|
||||
await expect(this.dialog.getByText("Invite Users")).toBeVisible();
|
||||
}
|
||||
|
||||
async addInviteEmail(email: string) {
|
||||
await this.inviteEmailInput.pressSequentially(email, { delay: 20 });
|
||||
await this.inviteEmailInput.press("Enter");
|
||||
// Wait for the chip to appear in the dialog
|
||||
await expect(this.dialog.getByText(email)).toBeVisible();
|
||||
}
|
||||
|
||||
async submitInvite() {
|
||||
await this.dialog.getByRole("button", { name: "Invite" }).click();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline role editing (Popover + OpenButton + LineItem)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async openRoleDropdown(email: string) {
|
||||
const row = this.getRowByEmail(email);
|
||||
const roleButton = row
|
||||
.locator("button")
|
||||
.filter({ hasText: /Basic|Admin|Global Curator|Slack User/ });
|
||||
await roleButton.click();
|
||||
await expect(this.popover).toBeVisible();
|
||||
}
|
||||
|
||||
async selectRole(roleName: string) {
|
||||
await this.popover.getByText(roleName).first().click();
|
||||
await this.waitForTableRefresh();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit groups modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stable locator for the edit-groups modal.
|
||||
*
|
||||
* We can't use the generic `dialog` getter (`.last()`) here because the
|
||||
* groups search opens a Radix Popover (also `role="dialog"`) inside the
|
||||
* modal, which shifts what `.last()` resolves to. Targeting by accessible
|
||||
* name keeps the reference pinned to the modal itself.
|
||||
*/
|
||||
get editGroupsDialog(): Locator {
|
||||
return this.page.getByRole("dialog", { name: /Edit User/ });
|
||||
}
|
||||
|
||||
/** The search input inside the edit groups modal. */
|
||||
get groupSearchInput(): Locator {
|
||||
return this.editGroupsDialog.getByPlaceholder("Search groups to join...");
|
||||
}
|
||||
|
||||
async openEditGroupsModal(email: string) {
|
||||
await this.openRowActions(email);
|
||||
await this.clickRowAction("Groups");
|
||||
await expect(
|
||||
this.editGroupsDialog.getByText("Edit User's Groups & Roles")
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async searchGroupsInModal(term: string) {
|
||||
// Click the input first to open the popover (Radix Popover.Trigger
|
||||
// wraps the input — fill() alone bypasses the trigger's click handler).
|
||||
await this.groupSearchInput.click();
|
||||
await this.groupSearchInput.fill(term);
|
||||
// The group name appears in the popover dropdown (nested dialog).
|
||||
// Use page-level search since the popover may be portaled.
|
||||
await expect(this.page.getByText(term).first()).toBeVisible();
|
||||
}
|
||||
|
||||
async toggleGroupInModal(groupName: string) {
|
||||
// LineItem renders as a <div>, not <button>.
|
||||
// The popover dropdown is a nested dialog inside the modal.
|
||||
await this.page
|
||||
.getByRole("dialog")
|
||||
.last()
|
||||
.getByText(groupName)
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
async saveGroupsModal() {
|
||||
await this.editGroupsDialog
|
||||
.getByRole("button", { name: "Save Changes" })
|
||||
.click();
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Playwright fixtures for Admin Users page tests.
|
||||
*
|
||||
* Provides:
|
||||
* - Authenticated admin page
|
||||
* - OnyxApiClient for API-level setup/teardown
|
||||
* - UsersAdminPage page object
|
||||
*/
|
||||
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { loginAs } from "@tests/e2e/utils/auth";
|
||||
import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient";
|
||||
import { UsersAdminPage } from "./UsersAdminPage";
|
||||
|
||||
export const test = base.extend<{
|
||||
adminPage: Page;
|
||||
api: OnyxApiClient;
|
||||
usersPage: UsersAdminPage;
|
||||
}>({
|
||||
adminPage: async ({ page }, use) => {
|
||||
await page.context().clearCookies();
|
||||
await loginAs(page, "admin");
|
||||
await use(page);
|
||||
},
|
||||
|
||||
api: async ({ adminPage }, use) => {
|
||||
const client = new OnyxApiClient(adminPage.request);
|
||||
await use(client);
|
||||
},
|
||||
|
||||
usersPage: async ({ adminPage }, use) => {
|
||||
const usersPage = new UsersAdminPage(adminPage);
|
||||
await use(usersPage);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
@@ -1,620 +0,0 @@
|
||||
/**
|
||||
* E2E Tests: Admin Users Page
|
||||
*
|
||||
* Tests the full users management page — search, filters, sorting,
|
||||
* inline role editing, row actions, invite modal, and group management.
|
||||
*
|
||||
* Read-only tests (layout, search, filters, sorting, pagination) run against
|
||||
* whatever users already exist in the database (at minimum 10 from global-setup:
|
||||
* 2 admins + 8 workers). Mutation tests create their own ephemeral users.
|
||||
*/
|
||||
|
||||
import { test, expect } from "./fixtures";
|
||||
import { TEST_ADMIN_CREDENTIALS } from "@tests/e2e/constants";
|
||||
import type { Browser } from "@playwright/test";
|
||||
import type { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `e2e-${prefix}-${Date.now()}@test.onyx`;
|
||||
}
|
||||
|
||||
const TEST_PASSWORD = "TestPassword123!";
|
||||
|
||||
/** Best-effort cleanup — logs failures instead of silently swallowing them. */
|
||||
async function softCleanup(fn: () => Promise<unknown>): Promise<void> {
|
||||
await fn().catch((e) => console.warn("cleanup:", e));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an authenticated API context for beforeAll/afterAll hooks.
|
||||
* Handles browser context lifecycle so callers only write the setup logic.
|
||||
*/
|
||||
async function withApiContext(
|
||||
browser: Browser,
|
||||
fn: (api: OnyxApiClient) => Promise<void>
|
||||
): Promise<void> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "admin_auth.json",
|
||||
});
|
||||
try {
|
||||
const { OnyxApiClient } = await import("@tests/e2e/utils/onyxApiClient");
|
||||
const api = new OnyxApiClient(context.request);
|
||||
await fn(api);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page load & layout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Users page — layout", () => {
|
||||
test("renders page title, invite button, search, and stats bar", async ({
|
||||
usersPage,
|
||||
}) => {
|
||||
await usersPage.goto();
|
||||
|
||||
await expect(usersPage.page.getByText("Users & Requests")).toBeVisible();
|
||||
await expect(usersPage.inviteButton).toBeVisible();
|
||||
await expect(usersPage.searchInput).toBeVisible();
|
||||
// Stats bar renders number and label as separate elements
|
||||
await expect(usersPage.page.getByText("active users")).toBeVisible();
|
||||
});
|
||||
|
||||
test("table renders with correct column headers", async ({ usersPage }) => {
|
||||
await usersPage.goto();
|
||||
|
||||
for (const header of [
|
||||
"Name",
|
||||
"Groups",
|
||||
"Account Type",
|
||||
"Status",
|
||||
"Last Updated",
|
||||
]) {
|
||||
await expect(
|
||||
usersPage.table.locator("th").filter({ hasText: header })
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("pagination shows summary and controls", async ({ usersPage }) => {
|
||||
await usersPage.goto();
|
||||
|
||||
await expect(usersPage.paginationSummary).toBeVisible();
|
||||
await expect(usersPage.paginationSummary).toContainText("Showing");
|
||||
});
|
||||
|
||||
test("CSV download button is visible in footer", async ({ usersPage }) => {
|
||||
await usersPage.goto();
|
||||
await expect(usersPage.downloadCsvButton).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search (uses existing DB users — at least admin_user@example.com)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Users page — search", () => {
|
||||
test("search filters table rows by email", async ({ usersPage }) => {
|
||||
await usersPage.goto();
|
||||
await usersPage.search(TEST_ADMIN_CREDENTIALS.email);
|
||||
|
||||
const row = usersPage.getRowByEmail(TEST_ADMIN_CREDENTIALS.email);
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
const rowCount = await usersPage.getVisibleRowCount();
|
||||
expect(rowCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("search with no results shows empty state", async ({ usersPage }) => {
|
||||
await usersPage.goto();
|
||||
await usersPage.search("zzz-no-match-exists-xyz@nowhere.invalid");
|
||||
|
||||
await expect(usersPage.page.getByText("No users found")).toBeVisible();
|
||||
});
|
||||
|
||||
test("clearing search restores all results", async ({ usersPage }) => {
|
||||
await usersPage.goto();
|
||||
|
||||
await usersPage.search("zzz-no-match-exists-xyz@nowhere.invalid");
|
||||
await expect(usersPage.page.getByText("No users found")).toBeVisible();
|
||||
|
||||
await usersPage.clearSearch();
|
||||
|
||||
await expect(usersPage.table).toBeVisible();
|
||||
const rowCount = await usersPage.getVisibleRowCount();
|
||||
expect(rowCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filters (uses existing DB users)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Users page — filters", () => {
|
||||
test("account types filter shows expected roles", async ({ usersPage }) => {
|
||||
await usersPage.goto();
|
||||
await usersPage.openAccountTypesFilter();
|
||||
|
||||
await expect(
|
||||
usersPage.popover.getByText("All Account Types").first()
|
||||
).toBeVisible();
|
||||
await expect(usersPage.popover.getByText("Admin").first()).toBeVisible();
|
||||
await expect(usersPage.popover.getByText("Basic").first()).toBeVisible();
|
||||
|
||||
await usersPage.closePopover();
|
||||
});
|
||||
|
||||
test("filtering by Admin role shows only admin users", async ({
|
||||
usersPage,
|
||||
}) => {
|
||||
await usersPage.goto();
|
||||
await usersPage.openAccountTypesFilter();
|
||||
await usersPage.selectAccountType("Admin");
|
||||
await usersPage.closePopover();
|
||||
|
||||
await expect(usersPage.accountTypesFilter).toContainText("Admin");
|
||||
|
||||
const rowCount = await usersPage.getVisibleRowCount();
|
||||
expect(rowCount).toBeGreaterThan(0);
|
||||
|
||||
// Every visible row's Account Type column must say "Admin"
|
||||
const roleTexts = await usersPage.getColumnTexts(2);
|
||||
for (const role of roleTexts) {
|
||||
expect(role).toBe("Admin");
|
||||
}
|
||||
});
|
||||
|
||||
test("status filter for Active shows only active users", async ({
|
||||
usersPage,
|
||||
}) => {
|
||||
await usersPage.goto();
|
||||
await usersPage.openStatusFilter();
|
||||
await usersPage.selectStatus("Active");
|
||||
await usersPage.closePopover();
|
||||
|
||||
await expect(usersPage.statusFilter).toContainText("Active");
|
||||
|
||||
const rowCount = await usersPage.getVisibleRowCount();
|
||||
expect(rowCount).toBeGreaterThan(0);
|
||||
|
||||
// Every visible row's Status column must say "Active"
|
||||
const statusTexts = await usersPage.getColumnTexts(3);
|
||||
for (const status of statusTexts) {
|
||||
expect(status).toBe("Active");
|
||||
}
|
||||
});
|
||||
|
||||
test("resetting filter shows all users again", async ({ usersPage }) => {
|
||||
await usersPage.goto();
|
||||
|
||||
await usersPage.openStatusFilter();
|
||||
await usersPage.selectStatus("Active");
|
||||
await usersPage.closePopover();
|
||||
const filteredCount = await usersPage.getVisibleRowCount();
|
||||
|
||||
await usersPage.openStatusFilter();
|
||||
await usersPage.selectStatus("All Status");
|
||||
await usersPage.closePopover();
|
||||
const allCount = await usersPage.getVisibleRowCount();
|
||||
|
||||
expect(allCount).toBeGreaterThanOrEqual(filteredCount);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sorting (uses existing DB users)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Users page — sorting", () => {
|
||||
test("clicking Name sort twice reverses row order", async ({ usersPage }) => {
|
||||
await usersPage.goto();
|
||||
|
||||
const firstRowBefore = await usersPage.tableRows.first().textContent();
|
||||
|
||||
// Click twice — first click may match default order; second guarantees reversal
|
||||
await usersPage.sortByColumn("Name");
|
||||
await usersPage.sortByColumn("Name");
|
||||
|
||||
const firstRowAfter = await usersPage.tableRows.first().textContent();
|
||||
expect(firstRowAfter).not.toBe(firstRowBefore);
|
||||
});
|
||||
|
||||
test("clicking Account Type sort twice reorders rows", async ({
|
||||
usersPage,
|
||||
}) => {
|
||||
await usersPage.goto();
|
||||
|
||||
const rolesBefore = await usersPage.getColumnTexts(2);
|
||||
|
||||
// Click twice to guarantee a different order from default
|
||||
await usersPage.sortByColumn("Account Type");
|
||||
await usersPage.sortByColumn("Account Type");
|
||||
|
||||
const rolesAfter = await usersPage.getColumnTexts(2);
|
||||
expect(rolesAfter.length).toBeGreaterThan(0);
|
||||
expect(rolesAfter).not.toEqual(rolesBefore);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pagination (uses existing DB users — need > 8 for multi-page)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Users page — pagination", () => {
|
||||
test("clicking page 2 navigates to second page", async ({ usersPage }) => {
|
||||
await usersPage.goto();
|
||||
|
||||
const summaryBefore = await usersPage.paginationSummary.textContent();
|
||||
|
||||
// With 10+ users and page size 8, page 2 should exist
|
||||
await usersPage.goToPage(2);
|
||||
|
||||
await expect(usersPage.paginationSummary).not.toHaveText(summaryBefore!);
|
||||
|
||||
// Go back to page 1
|
||||
await usersPage.goToPage(1);
|
||||
await expect(usersPage.paginationSummary).toHaveText(summaryBefore!);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invite users (creates ephemeral data)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Users page — invite users", () => {
|
||||
test("invite modal opens with correct structure", async ({ usersPage }) => {
|
||||
await usersPage.goto();
|
||||
await usersPage.openInviteModal();
|
||||
|
||||
await expect(usersPage.dialog.getByText("Invite Users")).toBeVisible();
|
||||
await expect(usersPage.inviteEmailInput).toBeVisible();
|
||||
|
||||
await usersPage.cancelModal();
|
||||
await expect(usersPage.dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("invite a user and verify Invite Pending status", async ({
|
||||
usersPage,
|
||||
api,
|
||||
}) => {
|
||||
const email = uniqueEmail("invite");
|
||||
|
||||
await usersPage.goto();
|
||||
await usersPage.openInviteModal();
|
||||
await usersPage.addInviteEmail(email);
|
||||
await usersPage.submitInvite();
|
||||
|
||||
await usersPage.expectToast(/Invited 1 user/);
|
||||
|
||||
// Reload and search
|
||||
await usersPage.goto();
|
||||
await usersPage.search(email);
|
||||
|
||||
const row = usersPage.getRowByEmail(email);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row).toContainText("Invite Pending");
|
||||
|
||||
// Cleanup
|
||||
await api.cancelInvite(email);
|
||||
});
|
||||
|
||||
test("invite multiple users at once", async ({ usersPage, api }) => {
|
||||
const email1 = uniqueEmail("multi1");
|
||||
const email2 = uniqueEmail("multi2");
|
||||
|
||||
await usersPage.goto();
|
||||
await usersPage.openInviteModal();
|
||||
|
||||
await usersPage.addInviteEmail(email1);
|
||||
await usersPage.addInviteEmail(email2);
|
||||
|
||||
await usersPage.submitInvite();
|
||||
await usersPage.expectToast(/Invited 2 users/);
|
||||
|
||||
// Cleanup
|
||||
await api.cancelInvite(email1);
|
||||
await api.cancelInvite(email2);
|
||||
});
|
||||
|
||||
test("invite modal shows error icon for invalid emails", async ({
|
||||
usersPage,
|
||||
}) => {
|
||||
await usersPage.goto();
|
||||
await usersPage.openInviteModal();
|
||||
|
||||
await usersPage.addInviteEmail("not-an-email");
|
||||
|
||||
// The chip should be rendered with an error state
|
||||
await expect(usersPage.dialog.getByText("not-an-email")).toBeVisible();
|
||||
|
||||
await usersPage.cancelModal();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row actions — deactivate / activate (creates ephemeral data)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Users page — deactivate & activate", () => {
|
||||
let testUserEmail: string;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
testUserEmail = uniqueEmail("deact");
|
||||
await withApiContext(browser, async (api) => {
|
||||
await api.registerUser(testUserEmail, TEST_PASSWORD);
|
||||
});
|
||||
});
|
||||
|
||||
test("deactivate and then reactivate a user", async ({ usersPage }) => {
|
||||
await usersPage.goto();
|
||||
await usersPage.search(testUserEmail);
|
||||
|
||||
const row = usersPage.getRowByEmail(testUserEmail);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row).toContainText("Active");
|
||||
|
||||
// Deactivate
|
||||
await usersPage.openRowActions(testUserEmail);
|
||||
await usersPage.clickRowAction("Deactivate User");
|
||||
|
||||
await expect(usersPage.dialog.getByText("Deactivate User")).toBeVisible();
|
||||
await expect(usersPage.dialog.getByText(testUserEmail)).toBeVisible();
|
||||
await expect(
|
||||
usersPage.dialog.getByText("will immediately lose access")
|
||||
).toBeVisible();
|
||||
|
||||
await usersPage.confirmModalAction("Deactivate");
|
||||
await usersPage.expectToast("User deactivated");
|
||||
|
||||
// Verify Inactive
|
||||
await usersPage.goto();
|
||||
await usersPage.search(testUserEmail);
|
||||
const inactiveRow = usersPage.getRowByEmail(testUserEmail);
|
||||
await expect(inactiveRow).toContainText("Inactive");
|
||||
|
||||
// Reactivate
|
||||
await usersPage.openRowActions(testUserEmail);
|
||||
await usersPage.clickRowAction("Activate User");
|
||||
|
||||
await expect(usersPage.dialog.getByText("Activate User")).toBeVisible();
|
||||
|
||||
await usersPage.confirmModalAction("Activate");
|
||||
await usersPage.expectToast("User activated");
|
||||
|
||||
// Verify Active again
|
||||
await usersPage.goto();
|
||||
await usersPage.search(testUserEmail);
|
||||
const reactivatedRow = usersPage.getRowByEmail(testUserEmail);
|
||||
await expect(reactivatedRow).toContainText("Active");
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
await withApiContext(browser, async (api) => {
|
||||
await softCleanup(() => api.deactivateUser(testUserEmail));
|
||||
await softCleanup(() => api.deleteUser(testUserEmail));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row actions — delete user (creates ephemeral data)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Users page — delete user", () => {
|
||||
test("delete an inactive user", async ({ usersPage, api }) => {
|
||||
const email = uniqueEmail("delete");
|
||||
await api.registerUser(email, TEST_PASSWORD);
|
||||
await api.deactivateUser(email);
|
||||
|
||||
await usersPage.goto();
|
||||
await usersPage.search(email);
|
||||
|
||||
const row = usersPage.getRowByEmail(email);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row).toContainText("Inactive");
|
||||
|
||||
await usersPage.openRowActions(email);
|
||||
await usersPage.clickRowAction("Delete User");
|
||||
|
||||
await expect(usersPage.dialog.getByText("Delete User")).toBeVisible();
|
||||
await expect(
|
||||
usersPage.dialog.getByText("will be permanently removed")
|
||||
).toBeVisible();
|
||||
|
||||
await usersPage.confirmModalAction("Delete");
|
||||
await usersPage.expectToast("User deleted");
|
||||
|
||||
// User gone
|
||||
await usersPage.goto();
|
||||
await usersPage.search(email);
|
||||
await expect(usersPage.page.getByText("No users found")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row actions — cancel invite (creates ephemeral data)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Users page — cancel invite", () => {
|
||||
test("cancel a pending invite", async ({ usersPage, api }) => {
|
||||
const email = uniqueEmail("cancel-inv");
|
||||
await api.inviteUsers([email]);
|
||||
|
||||
await usersPage.goto();
|
||||
await usersPage.search(email);
|
||||
|
||||
const row = usersPage.getRowByEmail(email);
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row).toContainText("Invite Pending");
|
||||
|
||||
await usersPage.openRowActions(email);
|
||||
await usersPage.clickRowAction("Cancel Invite");
|
||||
|
||||
await expect(
|
||||
usersPage.dialog.getByText("Cancel Invite").first()
|
||||
).toBeVisible();
|
||||
|
||||
await usersPage.confirmModalAction("Cancel Invite");
|
||||
await usersPage.expectToast("Invite cancelled");
|
||||
|
||||
// User gone
|
||||
await usersPage.goto();
|
||||
await usersPage.search(email);
|
||||
await expect(usersPage.page.getByText("No users found")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline role editing (creates ephemeral data)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Users page — inline role editing", () => {
|
||||
let testUserEmail: string;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
testUserEmail = uniqueEmail("role");
|
||||
await withApiContext(browser, async (api) => {
|
||||
await api.registerUser(testUserEmail, TEST_PASSWORD);
|
||||
});
|
||||
});
|
||||
|
||||
test("change user role from Basic to Admin and back", async ({
|
||||
usersPage,
|
||||
}) => {
|
||||
await usersPage.goto();
|
||||
await usersPage.search(testUserEmail);
|
||||
|
||||
const row = usersPage.getRowByEmail(testUserEmail);
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
// Initially Basic
|
||||
await expect(row.getByText("Basic")).toBeVisible();
|
||||
|
||||
// Change to Admin
|
||||
await usersPage.openRoleDropdown(testUserEmail);
|
||||
await usersPage.selectRole("Admin");
|
||||
await expect(row.getByText("Admin")).toBeVisible();
|
||||
|
||||
// Change back to Basic
|
||||
await usersPage.openRoleDropdown(testUserEmail);
|
||||
await usersPage.selectRole("Basic");
|
||||
await expect(row.getByText("Basic")).toBeVisible();
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
await withApiContext(browser, async (api) => {
|
||||
await softCleanup(() => api.deactivateUser(testUserEmail));
|
||||
await softCleanup(() => api.deleteUser(testUserEmail));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group management (creates ephemeral data)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Users page — group management", () => {
|
||||
let testUserEmail: string;
|
||||
let testGroupId: number;
|
||||
const groupName = `E2E-UsersTest-${Date.now()}`;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
testUserEmail = uniqueEmail("grp");
|
||||
await withApiContext(browser, async (api) => {
|
||||
await api.registerUser(testUserEmail, TEST_PASSWORD);
|
||||
testGroupId = await api.createUserGroup(groupName);
|
||||
await api.waitForGroupSync(testGroupId);
|
||||
});
|
||||
});
|
||||
|
||||
test("add user to group via edit groups modal", async ({ usersPage }) => {
|
||||
await usersPage.goto();
|
||||
await usersPage.search(testUserEmail);
|
||||
|
||||
const row = usersPage.getRowByEmail(testUserEmail);
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
await usersPage.openEditGroupsModal(testUserEmail);
|
||||
await usersPage.searchGroupsInModal(groupName);
|
||||
await usersPage.toggleGroupInModal(groupName);
|
||||
await usersPage.saveGroupsModal();
|
||||
await usersPage.expectToast("User updated");
|
||||
|
||||
// Verify group shows in the row
|
||||
await usersPage.goto();
|
||||
await usersPage.search(testUserEmail);
|
||||
const rowWithGroup = usersPage.getRowByEmail(testUserEmail);
|
||||
await expect(rowWithGroup).toContainText(groupName);
|
||||
});
|
||||
|
||||
test("remove user from group via edit groups modal", async ({
|
||||
usersPage,
|
||||
}) => {
|
||||
await usersPage.goto();
|
||||
await usersPage.search(testUserEmail);
|
||||
|
||||
const row = usersPage.getRowByEmail(testUserEmail);
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
await usersPage.openEditGroupsModal(testUserEmail);
|
||||
|
||||
// Group shows as joined — click to remove
|
||||
await usersPage.toggleGroupInModal(groupName);
|
||||
await usersPage.saveGroupsModal();
|
||||
await usersPage.expectToast("User updated");
|
||||
|
||||
// Verify group removed
|
||||
await usersPage.goto();
|
||||
await usersPage.search(testUserEmail);
|
||||
await expect(usersPage.getRowByEmail(testUserEmail)).not.toContainText(
|
||||
groupName
|
||||
);
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
await withApiContext(browser, async (api) => {
|
||||
await softCleanup(() => api.deleteUserGroup(testGroupId));
|
||||
await softCleanup(() => api.deactivateUser(testUserEmail));
|
||||
await softCleanup(() => api.deleteUser(testUserEmail));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stats bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("Users page — stats bar", () => {
|
||||
test("stats bar shows active users count", async ({ usersPage }) => {
|
||||
await usersPage.goto();
|
||||
// Number and label are separate elements; check for the label
|
||||
await expect(usersPage.page.getByText("active users")).toBeVisible();
|
||||
});
|
||||
|
||||
test("stats bar updates after inviting a user", async ({
|
||||
usersPage,
|
||||
api,
|
||||
}) => {
|
||||
const email = uniqueEmail("stats");
|
||||
|
||||
await usersPage.goto();
|
||||
|
||||
await usersPage.openInviteModal();
|
||||
await usersPage.addInviteEmail(email);
|
||||
await usersPage.submitInvite();
|
||||
await usersPage.expectToast(/Invited 1 user/);
|
||||
|
||||
// Stats bar should reflect the new invite
|
||||
await usersPage.goto();
|
||||
await expect(usersPage.page.getByText("pending invites")).toBeVisible();
|
||||
|
||||
// Cleanup
|
||||
await api.cancelInvite(email);
|
||||
});
|
||||
});
|
||||
@@ -588,34 +588,6 @@ export class OnyxApiClient {
|
||||
return responseData.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls until a user group has finished syncing (is_up_to_date === true).
|
||||
* Newly created groups start syncing immediately; many mutation endpoints
|
||||
* reject requests while the group is still syncing.
|
||||
*/
|
||||
async waitForGroupSync(
|
||||
groupId: number,
|
||||
timeout: number = 30000
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await this.get("/manage/admin/user-group");
|
||||
const groups = await res.json();
|
||||
const group = groups.find(
|
||||
(g: { id: number; is_up_to_date: boolean }) => g.id === groupId
|
||||
);
|
||||
return group?.is_up_to_date ?? false;
|
||||
},
|
||||
{
|
||||
message: `User group ${groupId} did not finish syncing`,
|
||||
timeout,
|
||||
}
|
||||
)
|
||||
.toBe(true);
|
||||
this.log(`User group ${groupId} finished syncing`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a user group.
|
||||
*
|
||||
@@ -1101,62 +1073,6 @@ export class OnyxApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
// === User Management Methods ===
|
||||
|
||||
async deactivateUser(email: string): Promise<void> {
|
||||
const response = await this.request.patch(
|
||||
`${this.baseUrl}/manage/admin/deactivate-user`,
|
||||
{ data: { user_email: email } }
|
||||
);
|
||||
await this.handleResponse(response, `Failed to deactivate user ${email}`);
|
||||
this.log(`Deactivated user: ${email}`);
|
||||
}
|
||||
|
||||
async activateUser(email: string): Promise<void> {
|
||||
const response = await this.request.patch(
|
||||
`${this.baseUrl}/manage/admin/activate-user`,
|
||||
{ data: { user_email: email } }
|
||||
);
|
||||
await this.handleResponse(response, `Failed to activate user ${email}`);
|
||||
this.log(`Activated user: ${email}`);
|
||||
}
|
||||
|
||||
async deleteUser(email: string): Promise<void> {
|
||||
const response = await this.request.delete(
|
||||
`${this.baseUrl}/manage/admin/delete-user`,
|
||||
{ data: { user_email: email } }
|
||||
);
|
||||
await this.handleResponse(response, `Failed to delete user ${email}`);
|
||||
this.log(`Deleted user: ${email}`);
|
||||
}
|
||||
|
||||
async cancelInvite(email: string): Promise<void> {
|
||||
const response = await this.request.patch(
|
||||
`${this.baseUrl}/manage/admin/remove-invited-user`,
|
||||
{ data: { user_email: email } }
|
||||
);
|
||||
await this.handleResponse(response, `Failed to cancel invite for ${email}`);
|
||||
this.log(`Cancelled invite for: ${email}`);
|
||||
}
|
||||
|
||||
async inviteUsers(emails: string[]): Promise<void> {
|
||||
const response = await this.put("/manage/admin/users", { emails });
|
||||
await this.handleResponse(response, `Failed to invite users`);
|
||||
this.log(`Invited users: ${emails.join(", ")}`);
|
||||
}
|
||||
|
||||
async setPersonalName(name: string): Promise<void> {
|
||||
const response = await this.request.patch(
|
||||
`${this.baseUrl}/user/personalization`,
|
||||
{ data: { name } }
|
||||
);
|
||||
await this.handleResponse(
|
||||
response,
|
||||
`Failed to set personal name to ${name}`
|
||||
);
|
||||
this.log(`Set personal name: ${name}`);
|
||||
}
|
||||
|
||||
// === Chat Session Methods ===
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user