mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-16 21:22:41 +00:00
Compare commits
5 Commits
jamison/fi
...
bo/hook_ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5639ff4cc8 | ||
|
|
5f628da4e8 | ||
|
|
e40f80cfe1 | ||
|
|
ca6ba2cca9 | ||
|
|
98ef5006ff |
25
.github/actions/slack-notify/action.yml
vendored
25
.github/actions/slack-notify/action.yml
vendored
@@ -10,6 +10,9 @@ inputs:
|
||||
failed-jobs:
|
||||
description: "Deprecated alias for details"
|
||||
required: false
|
||||
mention:
|
||||
description: "GitHub username to resolve to a Slack @-mention. Replaces {mention} in details."
|
||||
required: false
|
||||
title:
|
||||
description: "Title for the notification"
|
||||
required: false
|
||||
@@ -26,6 +29,7 @@ runs:
|
||||
SLACK_WEBHOOK_URL: ${{ inputs.webhook-url }}
|
||||
DETAILS: ${{ inputs.details }}
|
||||
FAILED_JOBS: ${{ inputs.failed-jobs }}
|
||||
MENTION_USER: ${{ inputs.mention }}
|
||||
TITLE: ${{ inputs.title }}
|
||||
REF_NAME: ${{ inputs.ref-name }}
|
||||
REPO: ${{ github.repository }}
|
||||
@@ -52,6 +56,27 @@ runs:
|
||||
DETAILS="$FAILED_JOBS"
|
||||
fi
|
||||
|
||||
# Resolve {mention} placeholder if a GitHub username was provided.
|
||||
# Looks up the username in user-mappings.json (co-located with this action)
|
||||
# and replaces {mention} with <@SLACK_ID> for a Slack @-mention.
|
||||
# Falls back to the plain GitHub username if not found in the mapping.
|
||||
if [ -n "$MENTION_USER" ]; then
|
||||
MAPPINGS_FILE="${GITHUB_ACTION_PATH}/user-mappings.json"
|
||||
slack_id="$(jq -r --arg gh "$MENTION_USER" 'to_entries[] | select(.value | ascii_downcase == ($gh | ascii_downcase)) | .key' "$MAPPINGS_FILE" 2>/dev/null | head -1)"
|
||||
|
||||
if [ -n "$slack_id" ]; then
|
||||
mention_text="<@${slack_id}>"
|
||||
else
|
||||
mention_text="${MENTION_USER}"
|
||||
fi
|
||||
|
||||
DETAILS="${DETAILS//\{mention\}/$mention_text}"
|
||||
TITLE="${TITLE//\{mention\}/}"
|
||||
else
|
||||
DETAILS="${DETAILS//\{mention\}/}"
|
||||
TITLE="${TITLE//\{mention\}/}"
|
||||
fi
|
||||
|
||||
normalize_multiline() {
|
||||
printf '%s' "$1" | awk 'BEGIN { ORS=""; first=1 } { if (!first) printf "\\n"; printf "%s", $0; first=0 }'
|
||||
}
|
||||
|
||||
18
.github/actions/slack-notify/user-mappings.json
vendored
Normal file
18
.github/actions/slack-notify/user-mappings.json
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"U05SAGZPEA1": "yuhongsun96",
|
||||
"U05SAH6UGUD": "Weves",
|
||||
"U07PWEQB7A5": "evan-onyx",
|
||||
"U07V1SM68KF": "joachim-danswer",
|
||||
"U08JZ9N3QNN": "raunakab",
|
||||
"U08L24NCLJE": "Subash-Mohan",
|
||||
"U090B9M07B2": "wenxi-onyx",
|
||||
"U094RASDP0Q": "duo-onyx",
|
||||
"U096L8ZQ85B": "justin-tahara",
|
||||
"U09AHV8UBQX": "jessicasingh7",
|
||||
"U09KAL5T3C2": "nmgarza5",
|
||||
"U09KPGVQ70R": "acaprau",
|
||||
"U09QR8KTSJH": "rohoswagger",
|
||||
"U09RB4NTXA4": "jmelahman",
|
||||
"U0A6K9VCY6A": "Danelegend",
|
||||
"U0AGC4KH71A": "Bo-Onyx"
|
||||
}
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
CHERRY_PICK_PR_URL: ${{ needs.cherry-pick-to-latest-release.outputs.cherry_pick_pr_url }}
|
||||
run: |
|
||||
source_pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${SOURCE_PR_NUMBER}"
|
||||
details="*Cherry-pick PR opened successfully.*\\n• source PR: ${source_pr_url}"
|
||||
details="*Cherry-pick PR opened successfully.*\\n• author: {mention}\\n• source PR: ${source_pr_url}"
|
||||
if [ -n "${CHERRY_PICK_PR_URL}" ]; then
|
||||
details="${details}\\n• cherry-pick PR: ${CHERRY_PICK_PR_URL}"
|
||||
fi
|
||||
@@ -221,6 +221,7 @@ jobs:
|
||||
uses: ./.github/actions/slack-notify
|
||||
with:
|
||||
webhook-url: ${{ secrets.CHERRY_PICK_PRS_WEBHOOK }}
|
||||
mention: ${{ needs.resolve-cherry-pick-request.outputs.merged_by }}
|
||||
details: ${{ steps.success-summary.outputs.details }}
|
||||
title: "✅ Automated Cherry-Pick PR Opened"
|
||||
ref-name: ${{ github.event.pull_request.base.ref }}
|
||||
@@ -275,20 +276,21 @@ jobs:
|
||||
else
|
||||
failed_job_label="cherry-pick-to-latest-release"
|
||||
fi
|
||||
failed_jobs="• ${failed_job_label}\\n• source PR: ${source_pr_url}\\n• reason: ${reason_text}"
|
||||
details="• author: {mention}\\n• ${failed_job_label}\\n• source PR: ${source_pr_url}\\n• reason: ${reason_text}"
|
||||
if [ -n "${MERGE_COMMIT_SHA}" ]; then
|
||||
failed_jobs="${failed_jobs}\\n• merge SHA: ${MERGE_COMMIT_SHA}"
|
||||
details="${details}\\n• merge SHA: ${MERGE_COMMIT_SHA}"
|
||||
fi
|
||||
if [ -n "${details_excerpt}" ]; then
|
||||
failed_jobs="${failed_jobs}\\n• excerpt: ${details_excerpt}"
|
||||
details="${details}\\n• excerpt: ${details_excerpt}"
|
||||
fi
|
||||
|
||||
echo "jobs=${failed_jobs}" >> "$GITHUB_OUTPUT"
|
||||
echo "details=${details}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Notify #cherry-pick-prs about cherry-pick failure
|
||||
uses: ./.github/actions/slack-notify
|
||||
with:
|
||||
webhook-url: ${{ secrets.CHERRY_PICK_PRS_WEBHOOK }}
|
||||
details: ${{ steps.failure-summary.outputs.jobs }}
|
||||
mention: ${{ needs.resolve-cherry-pick-request.outputs.merged_by }}
|
||||
details: ${{ steps.failure-summary.outputs.details }}
|
||||
title: "🚨 Automated Cherry-Pick Failed"
|
||||
ref-name: ${{ github.event.pull_request.base.ref }}
|
||||
|
||||
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]]
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type InteractiveStatefulInteraction,
|
||||
} from "@opal/core";
|
||||
import type { SizeVariant, WidthVariant } from "@opal/shared";
|
||||
import type { InteractiveContainerRoundingVariant } from "@opal/core";
|
||||
import type { TooltipSide } from "@opal/components";
|
||||
import type { IconFunctionComponent, IconProps } from "@opal/types";
|
||||
import { SvgChevronDownSmall } from "@opal/icons";
|
||||
@@ -80,6 +81,9 @@ type OpenButtonProps = Omit<InteractiveStatefulProps, "variant"> & {
|
||||
|
||||
/** Which side the tooltip appears on. */
|
||||
tooltipSide?: TooltipSide;
|
||||
|
||||
/** Override the default rounding derived from `size`. */
|
||||
roundingVariant?: InteractiveContainerRoundingVariant;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -95,6 +99,7 @@ function OpenButton({
|
||||
justifyContent,
|
||||
tooltip,
|
||||
tooltipSide = "top",
|
||||
roundingVariant: roundingVariantOverride,
|
||||
interaction,
|
||||
variant = "select-heavy",
|
||||
...statefulProps
|
||||
@@ -132,7 +137,8 @@ function OpenButton({
|
||||
heightVariant={size}
|
||||
widthVariant={width}
|
||||
roundingVariant={
|
||||
isLarge ? "default" : size === "2xs" ? "mini" : "compact"
|
||||
roundingVariantOverride ??
|
||||
(isLarge ? "default" : size === "2xs" ? "mini" : "compact")
|
||||
}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
|
||||
import { IS_DEV } from "@/lib/constants";
|
||||
|
||||
// Target format for OpenAI Realtime API
|
||||
const TARGET_SAMPLE_RATE = 24000;
|
||||
const CHUNK_INTERVAL_MS = 250;
|
||||
@@ -140,7 +138,7 @@ class VoiceRecorderSession {
|
||||
this.websocket.onerror = () => this.onError("Connection failed");
|
||||
|
||||
// Set up audio capture
|
||||
this.audioContext = new AudioContext();
|
||||
this.audioContext = new AudioContext({ sampleRate: TARGET_SAMPLE_RATE });
|
||||
this.sourceNode = this.audioContext.createMediaStreamSource(
|
||||
this.mediaStream
|
||||
);
|
||||
@@ -247,8 +245,9 @@ class VoiceRecorderSession {
|
||||
const { token } = await tokenResponse.json();
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const host = IS_DEV ? "localhost:8080" : window.location.host;
|
||||
const path = IS_DEV
|
||||
const isDev = window.location.port === "3000";
|
||||
const host = isDev ? "localhost:8080" : window.location.host;
|
||||
const path = isDev
|
||||
? "/voice/transcribe/stream"
|
||||
: "/api/voice/transcribe/stream";
|
||||
return `${protocol}//${host}${path}?token=${encodeURIComponent(token)}`;
|
||||
|
||||
@@ -9,7 +9,7 @@ import React from "react";
|
||||
export type FlexDirection = "row" | "column";
|
||||
export type JustifyContent = "start" | "center" | "end" | "between";
|
||||
export type AlignItems = "start" | "center" | "end" | "stretch";
|
||||
export type Length = "auto" | "fit" | "full";
|
||||
export type Length = "auto" | "fit" | "full" | number;
|
||||
|
||||
const flexDirectionClassMap: Record<FlexDirection, string> = {
|
||||
row: "flex-row",
|
||||
@@ -90,11 +90,12 @@ export const heightClassmap: Record<Length, string> = {
|
||||
* @remarks
|
||||
* - The component defaults to column layout when no direction is specified
|
||||
* - Full width and height by default
|
||||
* - Prevents style overrides (className and style props are not available)
|
||||
* - Accepts className for additional styling; style prop is not available
|
||||
* - Import using namespace import for consistent usage: `import * as GeneralLayouts from "@/layouts/general-layouts"`
|
||||
*/
|
||||
export interface SectionProps
|
||||
extends WithoutStyles<React.HtmlHTMLAttributes<HTMLDivElement>> {
|
||||
className?: string;
|
||||
flexDirection?: FlexDirection;
|
||||
justifyContent?: JustifyContent;
|
||||
alignItems?: AlignItems;
|
||||
@@ -116,6 +117,7 @@ export interface SectionProps
|
||||
* wrap a `Section` without affecting layout.
|
||||
*/
|
||||
function Section({
|
||||
className,
|
||||
flexDirection = "column",
|
||||
justifyContent = "center",
|
||||
alignItems = "center",
|
||||
@@ -137,13 +139,20 @@ function Section({
|
||||
flexDirectionClassMap[flexDirection],
|
||||
justifyClassMap[justifyContent],
|
||||
alignClassMap[alignItems],
|
||||
widthClassmap[width],
|
||||
heightClassmap[height],
|
||||
typeof width === "string" && widthClassmap[width],
|
||||
typeof height === "string" && heightClassmap[height],
|
||||
typeof height === "number" && "overflow-hidden",
|
||||
|
||||
wrap && "flex-wrap",
|
||||
dbg && "dbg-red"
|
||||
dbg && "dbg-red",
|
||||
className
|
||||
)}
|
||||
style={{ gap: `${gap}rem`, padding: `${padding}rem` }}
|
||||
style={{
|
||||
gap: `${gap}rem`,
|
||||
padding: `${padding}rem`,
|
||||
...(typeof width === "number" && { width: `${width}rem` }),
|
||||
...(typeof height === "number" && { height: `${height}rem` }),
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export const IS_DEV = process.env.NODE_ENV === "development";
|
||||
|
||||
export enum AuthType {
|
||||
BASIC = "basic",
|
||||
GOOGLE_OAUTH = "google_oauth",
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* Plays audio chunks as they arrive for smooth, low-latency playback.
|
||||
*/
|
||||
|
||||
import { IS_DEV } from "@/lib/constants";
|
||||
|
||||
/**
|
||||
* HTTPStreamingTTSPlayer - Uses HTTP streaming with MediaSource Extensions
|
||||
* for smooth, gapless audio playback. This is the recommended approach for
|
||||
@@ -384,8 +382,9 @@ export class WebSocketStreamingTTSPlayer {
|
||||
const { token } = await tokenResponse.json();
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const host = IS_DEV ? "localhost:8080" : window.location.host;
|
||||
const path = IS_DEV
|
||||
const isDev = window.location.port === "3000";
|
||||
const host = isDev ? "localhost:8080" : window.location.host;
|
||||
const path = isDev
|
||||
? "/voice/synthesize/stream"
|
||||
: "/api/voice/synthesize/stream";
|
||||
return `${protocol}//${host}${path}?token=${encodeURIComponent(token)}`;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { SvgX } from "@opal/icons";
|
||||
import { Button } from "@opal/components";
|
||||
@@ -10,6 +11,8 @@ export interface ChipProps {
|
||||
rightIcon?: React.FunctionComponent<IconProps>;
|
||||
onRemove?: () => void;
|
||||
smallLabel?: boolean;
|
||||
/** When true, applies warning-coloured styling to the right icon. */
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,16 +32,27 @@ export default function Chip({
|
||||
rightIcon: RightIcon,
|
||||
onRemove,
|
||||
smallLabel = true,
|
||||
error = false,
|
||||
}: ChipProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded-08 bg-background-tint-02">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-1.5 py-0.5 rounded-08",
|
||||
"bg-background-tint-02"
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon size={12} className="text-text-03" />}
|
||||
{children && (
|
||||
<Text figureSmallLabel={smallLabel} text03>
|
||||
{children}
|
||||
</Text>
|
||||
)}
|
||||
{RightIcon && <RightIcon size={14} className="text-text-03" />}
|
||||
{RightIcon && (
|
||||
<RightIcon
|
||||
size={14}
|
||||
className={cn(error ? "text-status-warning-05" : "text-text-03")}
|
||||
/>
|
||||
)}
|
||||
{onRemove && (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -107,9 +107,10 @@ const PopoverClose = PopoverPrimitive.Close;
|
||||
* </Popover.Content>
|
||||
* ```
|
||||
*/
|
||||
type PopoverWidths = "fit" | "md" | "lg" | "xl" | "trigger";
|
||||
type PopoverWidths = "fit" | "sm" | "md" | "lg" | "xl" | "trigger";
|
||||
const widthClasses: Record<PopoverWidths, string> = {
|
||||
fit: "w-fit",
|
||||
sm: "w-[10rem]",
|
||||
md: "w-[12rem]",
|
||||
lg: "w-[15rem]",
|
||||
xl: "w-[18rem]",
|
||||
@@ -120,25 +121,29 @@ interface PopoverContentProps
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
> {
|
||||
width?: PopoverWidths;
|
||||
/** Portal container. Set to a DOM element to render inside it (e.g. inside a modal). */
|
||||
container?: HTMLElement | null;
|
||||
ref?: React.Ref<React.ComponentRef<typeof PopoverPrimitive.Content>>;
|
||||
}
|
||||
function PopoverContent({
|
||||
width = "fit",
|
||||
container,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
ref,
|
||||
...props
|
||||
}: PopoverContentProps) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Portal container={container}>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={8}
|
||||
className={cn(
|
||||
"bg-background-neutral-00 p-1 z-popover rounded-12 overflow-hidden border shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"bg-background-neutral-00 p-1 z-popover rounded-12 border shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"max-h-[var(--radix-popover-content-available-height)]",
|
||||
"overflow-hidden",
|
||||
widthClasses[width]
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { cn } from "@/lib/utils";
|
||||
export interface SeparatorProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> {
|
||||
noPadding?: boolean;
|
||||
/** Custom horizontal padding in rem. Overrides the default padding. */
|
||||
paddingXRem?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,6 +36,7 @@ const Separator = React.forwardRef(
|
||||
(
|
||||
{
|
||||
noPadding,
|
||||
paddingXRem,
|
||||
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
@@ -46,9 +49,17 @@ const Separator = React.forwardRef(
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...(paddingXRem != null
|
||||
? {
|
||||
paddingLeft: `${paddingXRem}rem`,
|
||||
paddingRight: `${paddingXRem}rem`,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
className={cn(
|
||||
isHorizontal ? "w-full" : "h-full",
|
||||
!noPadding && (isHorizontal ? "py-4" : "px-4"),
|
||||
paddingXRem == null && !noPadding && (isHorizontal ? "py-4" : "px-4"),
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function ShadowDiv({
|
||||
}, [containerRef, checkScroll]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative min-h-0">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("overflow-y-auto", className)}
|
||||
@@ -118,7 +118,7 @@ export default function ShadowDiv({
|
||||
{!bottomOnly && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 left-0 right-0 pointer-events-none",
|
||||
"absolute top-0 left-0 right-0 pointer-events-none transition-opacity duration-150",
|
||||
showTopShadow ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
style={{
|
||||
@@ -132,7 +132,7 @@ export default function ShadowDiv({
|
||||
{!topOnly && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-0 left-0 right-0 pointer-events-none",
|
||||
"absolute bottom-0 left-0 right-0 pointer-events-none transition-opacity duration-150",
|
||||
showBottomShadow ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
style={{
|
||||
|
||||
@@ -97,16 +97,8 @@ function InputChipField({
|
||||
<Chip
|
||||
key={chip.id}
|
||||
onRemove={disabled ? undefined : () => onRemoveChip(chip.id)}
|
||||
rightIcon={
|
||||
chip.error
|
||||
? (props) => (
|
||||
<SvgAlertTriangle
|
||||
{...props}
|
||||
className="text-status-warning-text"
|
||||
/>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
rightIcon={chip.error ? SvgAlertTriangle : undefined}
|
||||
error={chip.error}
|
||||
smallLabel={layout === "stacked"}
|
||||
>
|
||||
{chip.label}
|
||||
@@ -124,7 +116,7 @@ function InputChipField({
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={chips.length === 0 ? placeholder : undefined}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"flex-1 min-w-[80px] h-[1.5rem] bg-transparent p-0.5 focus:outline-none",
|
||||
innerClasses[variant],
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef, useCallback } from "react";
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgUsers, SvgUser, SvgLogOut, SvgCheck } from "@opal/icons";
|
||||
import { Disabled } from "@opal/core";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import ShadowDiv from "@/refresh-components/ShadowDiv";
|
||||
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { UserRole, USER_ROLE_LABELS } from "@/lib/types";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import { addUserToGroup, removeUserFromGroup, setUserRole } from "./svc";
|
||||
import type { UserRow } from "./interfaces";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -33,7 +36,7 @@ const ASSIGNABLE_ROLES: UserRole[] = [
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EditGroupsModalProps {
|
||||
interface EditUserModalProps {
|
||||
user: UserRow & { id: string };
|
||||
onClose: () => void;
|
||||
onMutate: () => void;
|
||||
@@ -43,25 +46,16 @@ interface EditGroupsModalProps {
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function EditGroupsModal({
|
||||
export default function EditUserModal({
|
||||
user,
|
||||
onClose,
|
||||
onMutate,
|
||||
}: EditGroupsModalProps) {
|
||||
}: EditUserModalProps) {
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
const { data: allGroups, isLoading: groupsLoading } = useGroups();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const closeDropdown = useCallback(() => {
|
||||
// Delay to allow click events on dropdown items to fire before closing
|
||||
setTimeout(() => {
|
||||
if (!containerRef.current?.contains(document.activeElement)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}, 0);
|
||||
}, []);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<UserRole | "">(
|
||||
user.role ?? ""
|
||||
);
|
||||
@@ -95,6 +89,10 @@ export default function EditGroupsModal({
|
||||
);
|
||||
}, [memberGroupIds, initialMemberGroupIds]);
|
||||
|
||||
const visibleRoles = isPaidEnterpriseFeaturesEnabled
|
||||
? ASSIGNABLE_ROLES
|
||||
: ASSIGNABLE_ROLES.filter((r) => r !== UserRole.GLOBAL_CURATOR);
|
||||
|
||||
const hasRoleChange =
|
||||
user.role !== null && selectedRole !== "" && selectedRole !== user.role;
|
||||
const hasChanges = hasGroupChanges || hasRoleChange;
|
||||
@@ -160,10 +158,17 @@ export default function EditGroupsModal({
|
||||
};
|
||||
|
||||
const displayName = user.personal_name ?? user.email;
|
||||
const [contentEl, setContentEl] = useState<HTMLDivElement | null>(null);
|
||||
const contentRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setContentEl(node);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<Modal.Content width="sm">
|
||||
<Modal
|
||||
open
|
||||
onOpenChange={(isOpen) => !isOpen && !isSubmitting && onClose()}
|
||||
>
|
||||
<Modal.Content width="sm" ref={contentRef}>
|
||||
<Modal.Header
|
||||
icon={SvgUsers}
|
||||
title="Edit User's Groups & Roles"
|
||||
@@ -172,108 +177,119 @@ export default function EditGroupsModal({
|
||||
? `${user.personal_name} (${user.email})`
|
||||
: user.email
|
||||
}
|
||||
onClose={onClose}
|
||||
onClose={isSubmitting ? undefined : onClose}
|
||||
/>
|
||||
<Modal.Body twoTone>
|
||||
<Section
|
||||
gap={1}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
{/* Subsection: white card behind search + groups */}
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-2 bg-background-neutral-00 rounded-12" />
|
||||
<Section
|
||||
gap={0.5}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
<div ref={containerRef} className="relative">
|
||||
<InputTypeIn
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
if (!dropdownOpen) setDropdownOpen(true);
|
||||
}}
|
||||
onFocus={() => setDropdownOpen(true)}
|
||||
onBlur={closeDropdown}
|
||||
placeholder="Search groups to join..."
|
||||
leftSearchIcon
|
||||
/>
|
||||
{dropdownOpen && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-background-neutral-00 border border-border-02 rounded-12 shadow-md p-1">
|
||||
{groupsLoading ? (
|
||||
<Text as="p" text03 secondaryBody className="px-3 py-2">
|
||||
Loading groups...
|
||||
</Text>
|
||||
) : dropdownGroups.length === 0 ? (
|
||||
<Text as="p" text03 secondaryBody className="px-3 py-2">
|
||||
No groups found
|
||||
</Text>
|
||||
) : (
|
||||
<ShadowDiv className="max-h-[200px] flex flex-col gap-1">
|
||||
{dropdownGroups.map((group) => {
|
||||
const isMember = memberGroupIds.has(group.id);
|
||||
return (
|
||||
<LineItem
|
||||
key={group.id}
|
||||
icon={isMember ? SvgCheck : SvgUsers}
|
||||
description={`${group.users.length} ${
|
||||
group.users.length === 1 ? "user" : "users"
|
||||
}`}
|
||||
selected={isMember}
|
||||
emphasized={isMember}
|
||||
onMouseDown={(e: React.MouseEvent) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
>
|
||||
{group.name}
|
||||
</LineItem>
|
||||
);
|
||||
})}
|
||||
</ShadowDiv>
|
||||
<Section padding={0} height="auto" alignItems="stretch">
|
||||
<Section
|
||||
gap={0.5}
|
||||
padding={0.25}
|
||||
height={joinedGroups.length === 0 && !popoverOpen ? "auto" : 14.5}
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
className="bg-background-tint-02 rounded-08"
|
||||
>
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
{/* asChild merges trigger props onto this div instead of rendering a <button>.
|
||||
Without it, the trigger <button> would nest around InputTypeIn's
|
||||
internal IconButton <button>, causing a hydration error. */}
|
||||
<div>
|
||||
<InputTypeIn
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search groups to join..."
|
||||
leftSearchIcon
|
||||
/>
|
||||
</div>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
width="trigger"
|
||||
align="start"
|
||||
container={contentEl}
|
||||
>
|
||||
{groupsLoading ? (
|
||||
<LineItem skeleton description="Loading groups...">
|
||||
Loading...
|
||||
</LineItem>
|
||||
) : dropdownGroups.length === 0 ? (
|
||||
<LineItem
|
||||
skeleton
|
||||
description="Try a different search term."
|
||||
>
|
||||
No groups found
|
||||
</LineItem>
|
||||
) : (
|
||||
<ShadowDiv
|
||||
shadowHeight="0.75rem"
|
||||
className={cn(
|
||||
"flex flex-col gap-1 max-h-[15rem] rounded-08"
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
{dropdownGroups.map((group) => {
|
||||
const isMember = memberGroupIds.has(group.id);
|
||||
return (
|
||||
<LineItem
|
||||
key={group.id}
|
||||
icon={isMember ? SvgCheck : SvgUsers}
|
||||
description={`${group.users.length} ${
|
||||
group.users.length === 1 ? "user" : "users"
|
||||
}`}
|
||||
selected={isMember}
|
||||
emphasized={isMember}
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
>
|
||||
{group.name}
|
||||
</LineItem>
|
||||
);
|
||||
})}
|
||||
</ShadowDiv>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
|
||||
<ShadowDiv
|
||||
className={cn(" max-h-[11rem] flex flex-col gap-1 rounded-08")}
|
||||
shadowHeight="0.75rem"
|
||||
>
|
||||
{joinedGroups.length === 0 ? (
|
||||
<LineItem
|
||||
icon={SvgUsers}
|
||||
skeleton
|
||||
interactive={false}
|
||||
description={`${displayName} is not in any groups.`}
|
||||
muted
|
||||
>
|
||||
No groups joined
|
||||
No groups found
|
||||
</LineItem>
|
||||
) : (
|
||||
<ShadowDiv className="flex flex-col gap-1 max-h-[200px]">
|
||||
{joinedGroups.map((group) => (
|
||||
<div
|
||||
joinedGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className="bg-background-tint-01 rounded-08"
|
||||
>
|
||||
<LineItem
|
||||
key={group.id}
|
||||
className="bg-background-tint-01 rounded-08"
|
||||
icon={SvgUsers}
|
||||
description={`${group.users.length} ${
|
||||
group.users.length === 1 ? "user" : "users"
|
||||
}`}
|
||||
rightChildren={
|
||||
<SimpleTooltip
|
||||
tooltip="Remove from group"
|
||||
side="left"
|
||||
>
|
||||
<SvgLogOut height={16} width={16} />
|
||||
</SimpleTooltip>
|
||||
}
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
>
|
||||
<LineItem
|
||||
icon={SvgUsers}
|
||||
description={`${group.users.length} ${
|
||||
group.users.length === 1 ? "user" : "users"
|
||||
}`}
|
||||
rightChildren={
|
||||
<SvgLogOut className="w-4 h-4 text-text-03" />
|
||||
}
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
>
|
||||
{group.name}
|
||||
</LineItem>
|
||||
</div>
|
||||
))}
|
||||
</ShadowDiv>
|
||||
{group.name}
|
||||
</LineItem>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
</ShadowDiv>
|
||||
</Section>
|
||||
{user.role && (
|
||||
<>
|
||||
<Separator noPadding />
|
||||
@@ -291,7 +307,7 @@ export default function EditGroupsModal({
|
||||
>
|
||||
<InputSelect.Trigger />
|
||||
<InputSelect.Content>
|
||||
{user.role && !ASSIGNABLE_ROLES.includes(user.role) && (
|
||||
{user.role && !visibleRoles.includes(user.role) && (
|
||||
<InputSelect.Item
|
||||
key={user.role}
|
||||
value={user.role}
|
||||
@@ -300,7 +316,7 @@ export default function EditGroupsModal({
|
||||
{USER_ROLE_LABELS[user.role]}
|
||||
</InputSelect.Item>
|
||||
)}
|
||||
{ASSIGNABLE_ROLES.map((role) => (
|
||||
{visibleRoles.map((role) => (
|
||||
<InputSelect.Item
|
||||
key={role}
|
||||
value={role}
|
||||
@@ -319,7 +335,10 @@ export default function EditGroupsModal({
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button prominence="secondary" onClick={onClose}>
|
||||
<Button
|
||||
prominence="secondary"
|
||||
onClick={isSubmitting ? undefined : onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Disabled disabled={isSubmitting || !hasChanges}>
|
||||
@@ -12,7 +12,7 @@ import { Tag } from "@opal/components";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
|
||||
import EditGroupsModal from "./EditGroupsModal";
|
||||
import EditUserModal from "./EditUserModal";
|
||||
import type { UserRow, UserGroupInfo } from "./interfaces";
|
||||
|
||||
interface GroupsCellProps {
|
||||
@@ -184,7 +184,7 @@ export default function GroupsCell({
|
||||
)}
|
||||
</div>
|
||||
{showModal && user.id != null && (
|
||||
<EditGroupsModal
|
||||
<EditUserModal
|
||||
user={{ ...user, id: user.id }}
|
||||
onClose={() => setShowModal(false)}
|
||||
onMutate={onMutate}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgUsers } from "@opal/icons";
|
||||
import { SvgUsers, SvgAlertTriangle } from "@opal/icons";
|
||||
import { Disabled } from "@opal/core";
|
||||
import Modal, { BasicModalFooter } from "@/refresh-components/Modal";
|
||||
import InputChipField from "@/refresh-components/inputs/InputChipField";
|
||||
import type { ChipItem } from "@/refresh-components/inputs/InputChipField";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { inviteUsers } from "./svc";
|
||||
|
||||
@@ -145,9 +146,20 @@ export default function InviteUsersModal({
|
||||
onAdd={addEmail}
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
placeholder="Add emails to invite, comma separated"
|
||||
placeholder="Add an email and press enter"
|
||||
layout="stacked"
|
||||
/>
|
||||
{chips.some((c) => c.error) && (
|
||||
<div className="flex items-center gap-1 pt-1">
|
||||
<SvgAlertTriangle
|
||||
size={14}
|
||||
className="text-status-warning-05 shrink-0"
|
||||
/>
|
||||
<Text secondaryBody text03>
|
||||
Some email addresses are invalid and will be skipped.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
|
||||
@@ -166,6 +166,7 @@ export default function UserFilters({
|
||||
<Popover>
|
||||
<Popover.Trigger asChild>
|
||||
<FilterButton
|
||||
data-testid="filter-role"
|
||||
leftIcon={SvgUsers}
|
||||
active={hasRoleFilter}
|
||||
onClear={() => onRolesChange([])}
|
||||
@@ -213,6 +214,7 @@ export default function UserFilters({
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<FilterButton
|
||||
data-testid="filter-group"
|
||||
leftIcon={SvgUsers}
|
||||
active={hasGroupFilter}
|
||||
onClear={() => onGroupsChange([])}
|
||||
@@ -267,6 +269,7 @@ export default function UserFilters({
|
||||
<Popover>
|
||||
<Popover.Trigger asChild>
|
||||
<FilterButton
|
||||
data-testid="filter-status"
|
||||
leftIcon={SvgUsers}
|
||||
active={hasStatusFilter}
|
||||
onClear={() => onStatusesChange([])}
|
||||
|
||||
@@ -51,16 +51,6 @@ export default function UserRoleCell({ user, onMutate }: UserRoleCellProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (user.is_scim_synced) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Text as="span" mainUiBody text03>
|
||||
{USER_ROLE_LABELS[user.role] ?? user.role}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const applyRole = async (newRole: UserRole) => {
|
||||
if (isUpdatingRef.current) return;
|
||||
isUpdatingRef.current = true;
|
||||
@@ -89,47 +79,46 @@ export default function UserRoleCell({ user, onMutate }: UserRoleCellProps) {
|
||||
|
||||
const currentIcon = ROLE_ICONS[user.role] ?? SvgUser;
|
||||
|
||||
const visibleRoles = isPaidEnterpriseFeaturesEnabled
|
||||
? SELECTABLE_ROLES
|
||||
: SELECTABLE_ROLES.filter((r) => r !== UserRole.GLOBAL_CURATOR);
|
||||
|
||||
const roleItems = visibleRoles.map((role) => {
|
||||
const isSelected = user.role === role;
|
||||
const icon = ROLE_ICONS[role] ?? SvgUser;
|
||||
return (
|
||||
<LineItem
|
||||
key={role}
|
||||
icon={isSelected ? SvgCheck : icon}
|
||||
selected={isSelected}
|
||||
emphasized={isSelected}
|
||||
onClick={() => handleSelect(role)}
|
||||
>
|
||||
{USER_ROLE_LABELS[role]}
|
||||
</LineItem>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="[&_button]:rounded-08">
|
||||
<Disabled disabled={isUpdating}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<OpenButton
|
||||
icon={currentIcon}
|
||||
variant="select-tinted"
|
||||
width="full"
|
||||
justifyContent="between"
|
||||
>
|
||||
{USER_ROLE_LABELS[user.role]}
|
||||
</OpenButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="start">
|
||||
<div className="flex flex-col gap-1 p-1 min-w-[160px]">
|
||||
{SELECTABLE_ROLES.map((role) => {
|
||||
if (
|
||||
role === UserRole.GLOBAL_CURATOR &&
|
||||
!isPaidEnterpriseFeaturesEnabled
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const isSelected = user.role === role;
|
||||
const icon = ROLE_ICONS[role] ?? SvgUser;
|
||||
return (
|
||||
<LineItem
|
||||
key={role}
|
||||
icon={isSelected ? SvgCheck : icon}
|
||||
selected={isSelected}
|
||||
emphasized={isSelected}
|
||||
onClick={() => handleSelect(role)}
|
||||
>
|
||||
{USER_ROLE_LABELS[role]}
|
||||
</LineItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
</Disabled>
|
||||
</div>
|
||||
<Disabled disabled={isUpdating}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<OpenButton
|
||||
icon={currentIcon}
|
||||
variant="select-tinted"
|
||||
width="full"
|
||||
justifyContent="between"
|
||||
roundingVariant="compact"
|
||||
>
|
||||
{USER_ROLE_LABELS[user.role]}
|
||||
</OpenButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="start">
|
||||
<div className="flex flex-col gap-1 p-1 min-w-[160px]">
|
||||
{roleItems}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
</Disabled>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,12 +6,16 @@ import {
|
||||
SvgMoreHorizontal,
|
||||
SvgUsers,
|
||||
SvgXCircle,
|
||||
SvgTrash,
|
||||
SvgCheck,
|
||||
SvgUserCheck,
|
||||
SvgUserPlus,
|
||||
SvgUserX,
|
||||
SvgKey,
|
||||
} from "@opal/icons";
|
||||
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";
|
||||
@@ -21,21 +25,23 @@ import {
|
||||
deleteUser,
|
||||
cancelInvite,
|
||||
approveRequest,
|
||||
resetPassword,
|
||||
} from "./svc";
|
||||
import EditGroupsModal from "./EditGroupsModal";
|
||||
import EditUserModal from "./EditUserModal";
|
||||
import type { UserRow } from "./interfaces";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ModalType =
|
||||
| "deactivate"
|
||||
| "activate"
|
||||
| "delete"
|
||||
| "cancelInvite"
|
||||
| "editGroups"
|
||||
| null;
|
||||
enum Modal {
|
||||
DEACTIVATE = "deactivate",
|
||||
ACTIVATE = "activate",
|
||||
DELETE = "delete",
|
||||
CANCEL_INVITE = "cancelInvite",
|
||||
EDIT_GROUPS = "editGroups",
|
||||
RESET_PASSWORD = "resetPassword",
|
||||
}
|
||||
|
||||
interface UserRowActionsProps {
|
||||
user: UserRow;
|
||||
@@ -50,9 +56,10 @@ export default function UserRowActions({
|
||||
user,
|
||||
onMutate,
|
||||
}: UserRowActionsProps) {
|
||||
const [modal, setModal] = useState<ModalType>(null);
|
||||
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>,
|
||||
@@ -71,13 +78,40 @@ export default function UserRowActions({
|
||||
}
|
||||
}
|
||||
|
||||
const openModal = (type: ModalType) => {
|
||||
const openModal = (type: Modal) => {
|
||||
setPopoverOpen(false);
|
||||
setModal(type);
|
||||
};
|
||||
|
||||
// Status-aware action menus
|
||||
const actionButtons = (() => {
|
||||
// SCIM-managed users get limited actions — most changes would be
|
||||
// overwritten on the next IdP sync.
|
||||
if (user.is_scim_synced) {
|
||||
return (
|
||||
<>
|
||||
{user.id && (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgUsers}
|
||||
onClick={() => openModal(Modal.EDIT_GROUPS)}
|
||||
>
|
||||
Groups & Roles
|
||||
</Button>
|
||||
)}
|
||||
<Disabled disabled>
|
||||
<Button prominence="tertiary" variant="danger" icon={SvgUserX}>
|
||||
Deactivate User
|
||||
</Button>
|
||||
</Disabled>
|
||||
<Separator paddingXRem={0.5} />
|
||||
<Text as="p" secondaryBody text03 className="px-3 py-1">
|
||||
This is a synced SCIM user managed by your identity provider.
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
switch (user.status) {
|
||||
case UserStatus.INVITED:
|
||||
return (
|
||||
@@ -85,7 +119,7 @@ export default function UserRowActions({
|
||||
prominence="tertiary"
|
||||
variant="danger"
|
||||
icon={SvgXCircle}
|
||||
onClick={() => openModal("cancelInvite")}
|
||||
onClick={() => openModal(Modal.CANCEL_INVITE)}
|
||||
>
|
||||
Cancel Invite
|
||||
</Button>
|
||||
@@ -95,7 +129,7 @@ export default function UserRowActions({
|
||||
return (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgCheck}
|
||||
icon={SvgUserCheck}
|
||||
onClick={() => {
|
||||
setPopoverOpen(false);
|
||||
handleAction(
|
||||
@@ -115,15 +149,24 @@ export default function UserRowActions({
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgUsers}
|
||||
onClick={() => openModal("editGroups")}
|
||||
onClick={() => openModal(Modal.EDIT_GROUPS)}
|
||||
>
|
||||
Groups
|
||||
Groups & Roles
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgXCircle}
|
||||
onClick={() => openModal("deactivate")}
|
||||
icon={SvgKey}
|
||||
onClick={() => openModal(Modal.RESET_PASSWORD)}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
<Separator paddingXRem={0.5} />
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
variant="danger"
|
||||
icon={SvgUserX}
|
||||
onClick={() => openModal(Modal.DEACTIVATE)}
|
||||
>
|
||||
Deactivate User
|
||||
</Button>
|
||||
@@ -133,27 +176,19 @@ export default function UserRowActions({
|
||||
case UserStatus.INACTIVE:
|
||||
return (
|
||||
<>
|
||||
{user.id && (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgUsers}
|
||||
onClick={() => openModal("editGroups")}
|
||||
>
|
||||
Groups
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgCheck}
|
||||
onClick={() => openModal("activate")}
|
||||
icon={SvgUserPlus}
|
||||
onClick={() => openModal(Modal.ACTIVATE)}
|
||||
>
|
||||
Activate User
|
||||
</Button>
|
||||
<Separator paddingXRem={0.5} />
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
variant="danger"
|
||||
icon={SvgTrash}
|
||||
onClick={() => openModal("delete")}
|
||||
icon={SvgUserX}
|
||||
onClick={() => openModal(Modal.DELETE)}
|
||||
>
|
||||
Delete User
|
||||
</Button>
|
||||
@@ -167,36 +202,39 @@ export default function UserRowActions({
|
||||
}
|
||||
})();
|
||||
|
||||
// SCIM-managed users cannot be modified from the UI — changes would be
|
||||
// overwritten on the next IdP sync.
|
||||
if (user.is_scim_synced) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<Button prominence="tertiary" icon={SvgMoreHorizontal} />
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="end">
|
||||
<div className="flex flex-col gap-0.5 p-1">{actionButtons}</div>
|
||||
<Popover.Content align="end" width="sm">
|
||||
<Section
|
||||
gap={0.5}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
{actionButtons}
|
||||
</Section>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
|
||||
{modal === "editGroups" && user.id && (
|
||||
<EditGroupsModal
|
||||
{modal === Modal.EDIT_GROUPS && user.id && (
|
||||
<EditUserModal
|
||||
user={user as UserRow & { id: string }}
|
||||
onClose={() => setModal(null)}
|
||||
onMutate={onMutate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modal === "cancelInvite" && (
|
||||
{modal === Modal.CANCEL_INVITE && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgXCircle}
|
||||
icon={(props) => (
|
||||
<SvgUserX {...props} className="text-action-danger-05" />
|
||||
)}
|
||||
title="Cancel Invite"
|
||||
onClose={() => setModal(null)}
|
||||
onClose={isSubmitting ? undefined : () => setModal(null)}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
@@ -208,7 +246,7 @@ export default function UserRowActions({
|
||||
);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
Cancel Invite
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
@@ -222,9 +260,11 @@ export default function UserRowActions({
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
{modal === "deactivate" && (
|
||||
{modal === Modal.DEACTIVATE && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgXCircle}
|
||||
icon={(props) => (
|
||||
<SvgUserX {...props} className="text-action-danger-05" />
|
||||
)}
|
||||
title="Deactivate User"
|
||||
onClose={isSubmitting ? undefined : () => setModal(null)}
|
||||
submit={
|
||||
@@ -254,9 +294,9 @@ export default function UserRowActions({
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
{modal === "activate" && (
|
||||
{modal === Modal.ACTIVATE && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgCheck}
|
||||
icon={SvgUserPlus}
|
||||
title="Activate User"
|
||||
onClose={isSubmitting ? undefined : () => setModal(null)}
|
||||
submit={
|
||||
@@ -283,9 +323,11 @@ export default function UserRowActions({
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
{modal === "delete" && (
|
||||
{modal === Modal.DELETE && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgTrash}
|
||||
icon={(props) => (
|
||||
<SvgUserX {...props} className="text-action-danger-05" />
|
||||
)}
|
||||
title="Delete User"
|
||||
onClose={isSubmitting ? undefined : () => setModal(null)}
|
||||
submit={
|
||||
@@ -313,6 +355,80 @@ export default function UserRowActions({
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
{modal === Modal.RESET_PASSWORD && (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,6 +237,7 @@ export default function UsersTable({
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
tooltip="Download CSV"
|
||||
aria-label="Download CSV"
|
||||
onClick={() => {
|
||||
downloadUsersCsv().catch((err) => {
|
||||
toast.error(
|
||||
|
||||
@@ -127,6 +127,20 @@ export async function inviteUsers(emails: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetPassword(
|
||||
email: string
|
||||
): Promise<{ user_id: string; new_password: string }> {
|
||||
const res = await fetch("/api/password/reset_password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_email: email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorDetail(res, "Failed to reset password"));
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function downloadUsersCsv(): Promise<void> {
|
||||
const res = await fetch("/api/manage/users/download");
|
||||
if (!res.ok) {
|
||||
|
||||
Reference in New Issue
Block a user