Compare commits

..

5 Commits

Author SHA1 Message Date
Bo-Onyx
5639ff4cc8 chore(hooks): Define Hook Point 2026-03-16 12:01:26 -07:00
dependabot[bot]
5f628da4e8 chore(deps): bump authlib from 1.6.7 to 1.6.9 (#9370)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-03-16 17:21:05 +00:00
Jamison Lahman
e40f80cfe1 chore(posthog): allow no-op client in DEV_MODE (#9357) 2026-03-16 16:55:00 +00:00
Nikolas Garza
ca6ba2cca9 fix(admin): users page UI/UX polish (#9366) 2026-03-16 15:27:03 +00:00
Nikolas Garza
98ef5006ff feat(ci): add Slack @-mention support to slack-notify action (#9359) 2026-03-16 15:26:32 +00:00
32 changed files with 818 additions and 262 deletions

View File

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

View 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"
}

View File

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

View File

View 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

View File

View 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."""

View 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"],
}

View 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())

View File

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

View 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", [])

View 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
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,3 @@
export const IS_DEV = process.env.NODE_ENV === "development";
export enum AuthType {
BASIC = "basic",
GOOGLE_OAUTH = "google_oauth",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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([])}

View File

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

View File

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

View File

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

View File

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