From 8f083695873c829831b32fd5774d3a8a5a41307b Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 11 Aug 2026 12:17:57 +0200 Subject: [PATCH 1/2] feat(example): allow custom values and an empty label in SearchableSelect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two optional props, both additive — existing callers are unaffected: - `allowCustomValue` offers the raw search query as a selectable option, so a caller can target something the option list does not know about (a channel the client has never loaded, for instance). Only surfaced when the query matches nothing or already looks fully qualified (`type:id`), so it does not clutter searches that do match. - `emptyLabel` gives the trigger something to show when `value` matches no option. The first-option fallback is kept for callers that pass neither prop, so nothing changes for the WS event dialog. Co-Authored-By: Claude Opus 5 --- .../vite/src/AppSettings/SearchableSelect.tsx | 49 ++++++++++++++++--- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/examples/vite/src/AppSettings/SearchableSelect.tsx b/examples/vite/src/AppSettings/SearchableSelect.tsx index 816ad5a7b..5e1714e57 100644 --- a/examples/vite/src/AppSettings/SearchableSelect.tsx +++ b/examples/vite/src/AppSettings/SearchableSelect.tsx @@ -60,6 +60,7 @@ const SearchableSelectOptionItem = ({ }; const SearchableSelectDropdownItems = ({ + allowCustomValue, onSearchChange, onSelect, options, @@ -67,6 +68,7 @@ const SearchableSelectDropdownItems = ({ searchQuery, selectedValue, }: { + allowCustomValue: boolean; onSearchChange: (value: string) => void; onSelect: (value: T) => void; options: SearchableSelectOption[]; @@ -74,10 +76,21 @@ const SearchableSelectDropdownItems = ({ searchQuery: string; selectedValue: T; }) => { - const normalizedQuery = searchQuery.trim().toLowerCase(); + const trimmedQuery = searchQuery.trim(); + const normalizedQuery = trimmedQuery.toLowerCase(); const filteredOptions = options.filter((option) => option.label.toLowerCase().includes(normalizedQuery), ); + // Lets the caller target something the option list does not know about. Offered only when the + // query cannot be satisfied from the list, or already looks fully qualified (`type:id`), so it + // does not clutter ordinary searches that do match. + const customOption = + allowCustomValue && + trimmedQuery && + (trimmedQuery.includes(':') || filteredOptions.length === 0) && + !options.some((option) => option.value === trimmedQuery) + ? ({ label: `Use "${trimmedQuery}"`, value: trimmedQuery as T } as const) + : null; return ( <> @@ -95,6 +108,13 @@ const SearchableSelectDropdownItems = ({ value={searchQuery} /> + {customOption && ( + + )} {filteredOptions.map((option) => ( ({ selected={selectedValue === option.value} /> ))} - {filteredOptions.length === 0 && ( + {filteredOptions.length === 0 && !customOption && (
No matching options
)} @@ -111,19 +131,33 @@ const SearchableSelectDropdownItems = ({ }; export const SearchableSelect = ({ + allowCustomValue = false, + emptyLabel, onChange, options, searchPlaceholder, value, }: { + /** Offer the raw search query as a selectable option when it matches nothing. */ + allowCustomValue?: boolean; + /** Trigger text when `value` matches no option. Defaults to the existing first-option fallback. */ + emptyLabel?: string; onChange: (value: T) => void; options: SearchableSelectOption[]; searchPlaceholder: string; value: T; }) => { const [searchQuery, setSearchQuery] = useState(''); - const selectedOption = - options.find((option) => option.value === value) ?? options[0] ?? null; + const selectedOption = options.find((option) => option.value === value) ?? null; + // With a free-text value the trigger must show what was typed even though it is not an option. + // Falls back to the first option only when neither new prop is in play, preserving the previous + // behaviour for existing callers. + const triggerLabel = + selectedOption?.label ?? + (allowCustomValue && value ? value : undefined) ?? + emptyLabel ?? + options[0]?.label ?? + ''; const TriggerComponent = useMemo( () => @@ -140,16 +174,14 @@ export const SearchableSelect = ({ ref={(element) => assignReferenceRef(referenceRef, element)} type='button' > - - {selectedOption?.label ?? ''} - + {triggerLabel} ); }, - [selectedOption], + [triggerLabel], ); return ( @@ -163,6 +195,7 @@ export const SearchableSelect = ({ TriggerComponent={TriggerComponent} > Date: Tue, 11 Aug 2026 12:18:44 +0200 Subject: [PATCH 2/2] feat(example): server-side client prompt dialog behind a flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an Actions-menu dialog that builds a server-side StreamChat client in the browser and invokes a registered method against it. Motivating case: writing another member's channel data, which a browser client cannot do. Local debugging aid only. Gated behind `?server_side_client=1` so it never appears unless asked for; the secret is held in component state for the lifetime of the dialog and is never persisted to localStorage, the URL, or anywhere else. An API secret grants full admin access — this must not ship in a real client bundle. Three v10 constraints shape the implementation: - `new StreamChat(key, secret)` does not exist — the constructor takes `(key, options)` — and `jsonwebtoken` is mapped to false in `package.json#browser`, so no server token can be minted in a browser bundle. The `{"server":true}` HS256 token is signed with Web Crypto and injected into `tokenManager`, which is what `_getToken()` reads for the Authorization header. - `client.channel(...)` throws without a connected user, and the generated `updateMemberPartial` sends no `user_id`, so it can only write the caller's own membership. Both are avoided by issuing requests through `client.api.sendRequest` — the same primitive the generated APIs use, which accepts the query params the generated wrappers drop. - No connection is opened; a server-side client is stateless, so there is no connect step. "Check secret" is an optional `getAppSettings` probe that surfaces a bad credential before a payload is composed. Entities, methods, payload templates and invocation live in a registry, so adding a method needs no dialog changes. A member picker writes `user_id` into the payload, reading its value back out of the JSON so the two cannot drift. The secret field is a `type="text"` input masked with `-webkit-text-security` rather than `type="password"`: 1Password decorates password fields and its injected UI steals focus, which closes undocked Chrome DevTools on the first keystroke. Run outcomes are reported through a NotificationList rendered inside the dialog, scoped by emitter. Emitting with `targetPanels: ['modal']` keeps them out of the channel's own notification list. Co-Authored-By: Claude Opus 5 --- .../AppSettings/ActionsMenu/ActionsMenu.tsx | 32 ++ .../ServerSideClientPromptDialog.tsx | 525 ++++++++++++++++++ .../ServerSideClientPromptDialog/index.ts | 4 + .../serverSideClient.ts | 97 ++++ .../serverSideClientFlag.ts | 20 + .../serverSideMethods.ts | 163 ++++++ .../vite/src/AppSettings/AppSettings.scss | 191 +++++++ 7 files changed, 1032 insertions(+) create mode 100644 examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/ServerSideClientPromptDialog.tsx create mode 100644 examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/index.ts create mode 100644 examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideClient.ts create mode 100644 examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideClientFlag.ts create mode 100644 examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/serverSideMethods.ts diff --git a/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx b/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx index 1c1be781d..e67622a2d 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx +++ b/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx @@ -23,8 +23,17 @@ import { webSocketEventPromptDialogId, } from './WebSocketEventPromptDialog'; +import { + isServerSideClientEnabled, + ServerSideClientPromptDialog, + serverSideClientPromptDialogId, +} from './ServerSideClientPromptDialog'; + const actionsMenuDialogId = 'app-actions-menu'; +// Read once at module scope — the flag comes from the URL and does not change within a session. +const serverSideClientEnabled = isServerSideClientEnabled(); + const ActionsMenuButton = ({ iconOnly, isOpen, @@ -79,6 +88,9 @@ export const ActionsMenu = ({ iconOnly = true }: { iconOnly?: boolean }) => { const { dialog: webSocketEventDialog } = useDialogOnNearestManager({ id: webSocketEventPromptDialogId, }); + const { dialog: serverSideClientDialog } = useDialogOnNearestManager({ + id: serverSideClientPromptDialogId, + }); const menuIsOpen = useDialogIsOpen(actionsMenuDialogId, dialogManager?.id); return ( @@ -103,10 +115,16 @@ export const ActionsMenu = ({ iconOnly = true }: { iconOnly?: boolean }) => { + {serverSideClientEnabled && ( + + )} + {serverSideClientEnabled && ( + + )} ); }; @@ -152,3 +170,17 @@ function TriggerWebSocketEventAction({ onTrigger }: { onTrigger: () => void }) { /> ); } + +function TriggerServerSideClientAction({ onTrigger }: { onTrigger: () => void }) { + const { closeMenu } = useContextMenuContext(); + + return ( + { + closeMenu(); + onTrigger(); + }} + /> + ); +} diff --git a/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/ServerSideClientPromptDialog.tsx b/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/ServerSideClientPromptDialog.tsx new file mode 100644 index 000000000..9d5205aa7 --- /dev/null +++ b/examples/vite/src/AppSettings/ActionsMenu/ServerSideClientPromptDialog/ServerSideClientPromptDialog.tsx @@ -0,0 +1,525 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { Notification, StreamChat } from 'stream-chat'; +import { + NotificationList, + Prompt, + useChatContext, + useDialogIsOpen, + useDialogOnNearestManager, + useNotificationApi, +} from 'stream-chat-react'; + +import { DraggableDialog } from '../DraggableDialog'; +import { SearchableSelect, type SearchableSelectOption } from '../../SearchableSelect'; +import { createServerSideClient, verifyServerSideClient } from './serverSideClient'; +import { + type ChannelMemberSummary, + fetchChannelMembers, + findMethod, + formatPayloadTemplate, + getMethodsForEntity, + MEMBER_USER_ID_KEY, + serverSideEntities, + type ServerSideEntity, +} from './serverSideMethods'; + +export const serverSideClientPromptDialogId = 'app-server-side-client-prompt-dialog'; + +const serverSideClientEmitter = 'vite-preview/ServerSideClientPromptDialog'; + +const isServerSideClientNotification = (notification: Notification) => + notification.origin?.emitter === serverSideClientEmitter; + +const toMessage = (error: unknown) => + error instanceof Error ? error.message : String(error); + +const Field = ({ + children, + hint, + label, +}: { + children: React.ReactNode; + hint?: string; + label: string; +}) => ( +
+ {label} + {children} + {hint &&

{hint}

} +
+); + +const StepHeading = ({ index, title }: { index: number; title: string }) => ( +
+ {index} + {title} +
+); + +const entityOptions: SearchableSelectOption[] = serverSideEntities.map( + ({ label, value }) => ({ label, value }), +); + +export const ServerSideClientPromptDialog = ({ + referenceElement, +}: { + referenceElement: HTMLElement | null; +}) => { + const { client: appClient } = useChatContext(); + const { addNotification } = useNotificationApi(); + const { dialog, dialogManager } = useDialogOnNearestManager({ + id: serverSideClientPromptDialogId, + }); + const dialogIsOpen = useDialogIsOpen(serverSideClientPromptDialogId, dialogManager?.id); + + const [secret, setSecret] = useState(''); + const [entity, setEntity] = useState('channel'); + const [cid, setCid] = useState(''); + const [methodId, setMethodId] = useState(''); + const [payload, setPayload] = useState(''); + + const [isRunning, setIsRunning] = useState(false); + const [isChecking, setIsChecking] = useState(false); + const [secretCheck, setSecretCheck] = useState(null); + const [result, setResult] = useState(null); + const [runError, setRunError] = useState(null); + + const [fetchedMembers, setFetchedMembers] = useState([]); + const [isFetchingMembers, setIsFetchingMembers] = useState(false); + const [memberError, setMemberError] = useState(null); + + // A server-side client is stateless — no WS, no session — so it is just a token-signing wrapper + // around REST calls. Cached per secret purely to avoid re-signing on every Run. + const clientCacheRef = useRef<{ client: StreamChat; secret: string } | null>(null); + + const methods = useMemo(() => getMethodsForEntity(entity), [entity]); + const selectedMethod = useMemo(() => findMethod(methodId), [methodId]); + const methodOptions = useMemo[]>( + () => methods.map((method) => ({ label: method.label, value: method.id })), + [methods], + ); + + // Channels the client has loaded. Recomputed each time the dialog opens rather than subscribed + // to — `activeChannels` is a plain record with no change notification, and a debugging dialog + // does not need it live. `allowCustomValue` covers anything not in the list. + const channelOptions = useMemo[]>(() => { + if (!dialogIsOpen) return []; + + return Object.values(appClient.activeChannels) + .map((activeChannel) => activeChannel.cid) + .filter((activeChannelCid): activeChannelCid is string => !!activeChannelCid) + .sort((left, right) => left.localeCompare(right)) + .map((activeChannelCid) => ({ + label: activeChannelCid, + value: activeChannelCid, + })); + }, [appClient, dialogIsOpen]); + + const localMembers = useMemo(() => { + if (!dialogIsOpen || !cid) return []; + + const members = appClient.activeChannels[cid]?.state?.members ?? {}; + + return Object.values(members) + .map((member) => ({ + name: member.user?.name, + userId: member.user_id ?? member.user?.id ?? '', + })) + .filter((member) => !!member.userId); + }, [appClient, cid, dialogIsOpen]); + + const memberOptions = useMemo[]>(() => { + // Server results win on collision — they are the authoritative copy. + const byUserId = new Map(); + [...localMembers, ...fetchedMembers].forEach((member) => { + byUserId.set(member.userId, member); + }); + + return [...byUserId.values()] + .map(({ name, userId }) => ({ + label: name ? `${name} — ${userId}` : userId, + value: userId, + })) + .sort((left, right) => left.label.localeCompare(right.label)); + }, [fetchedMembers, localMembers]); + + // The payload is the single source of truth for `user_id`, so the picker reads its value back + // out of the JSON. A hand-edited id shows up as the selection, and the two cannot drift apart. + const payloadUserId = useMemo(() => { + try { + const parsed = JSON.parse(payload) as Record; + const value = parsed?.[MEMBER_USER_ID_KEY]; + + return typeof value === 'string' ? value : ''; + } catch { + return ''; + } + }, [payload]); + + const resetState = useCallback(() => { + setSecret(''); + setEntity('channel'); + setCid(''); + setMethodId(''); + setPayload(''); + setIsRunning(false); + setIsChecking(false); + setSecretCheck(null); + setResult(null); + setRunError(null); + setFetchedMembers([]); + setIsFetchingMembers(false); + setMemberError(null); + clientCacheRef.current = null; + }, []); + + // Drops the secret and the cached privileged client as soon as the dialog closes. + useEffect(() => { + if (dialogIsOpen) return; + resetState(); + }, [dialogIsOpen, resetState]); + + // A new secret invalidates the cached client and any previous check result. + useEffect(() => { + clientCacheRef.current = null; + setSecretCheck(null); + }, [secret]); + + // Server-fetched members belong to one CID; changing channel makes them wrong. + useEffect(() => { + setFetchedMembers([]); + setMemberError(null); + }, [cid]); + + // Selecting a different entity invalidates the method and its payload template. + useEffect(() => { + setMethodId(''); + setPayload(''); + setResult(null); + setRunError(null); + }, [entity]); + + const getServerClient = useCallback(async () => { + const trimmedSecret = secret.trim(); + + if (!trimmedSecret) throw new Error('Enter the API secret first.'); + + if (clientCacheRef.current?.secret === trimmedSecret) { + return clientCacheRef.current.client; + } + + const client = await createServerSideClient({ + apiKey: appClient.key, + secret: trimmedSecret, + }); + clientCacheRef.current = { client, secret: trimmedSecret }; + + return client; + }, [appClient.key, secret]); + + // Optional convenience: confirms the secret is right before you bother composing a payload. + const checkSecret = useCallback(async () => { + setIsChecking(true); + setSecretCheck(null); + try { + await verifyServerSideClient(await getServerClient()); + setSecretCheck('Secret accepted.'); + } catch (error) { + setSecretCheck(toMessage(error)); + } finally { + setIsChecking(false); + } + }, [getServerClient]); + + const loadMembers = useCallback(async () => { + setIsFetchingMembers(true); + setMemberError(null); + try { + const members = await fetchChannelMembers({ + cid: cid.trim(), + client: await getServerClient(), + }); + setFetchedMembers(members); + + if (!members.length) setMemberError('That channel reported no members.'); + } catch (error) { + setMemberError(toMessage(error)); + } finally { + setIsFetchingMembers(false); + } + }, [cid, getServerClient]); + + // Writes the picked id into the payload rather than holding it in separate state. Overwrites any + // existing `user_id` — picking a member is the more deliberate action of the two. + const selectMember = useCallback((userId: string) => { + setPayload((current) => { + try { + const parsed = JSON.parse(current) as Record; + + return `${JSON.stringify({ ...parsed, [MEMBER_USER_ID_KEY]: userId }, null, 2)}\n`; + } catch { + // Mid-edit invalid JSON — leave the textarea untouched rather than destroying work. + setMemberError( + 'The payload is not valid JSON right now, so `user_id` was left alone.', + ); + return current; + } + }); + }, []); + + const selectMethod = useCallback((nextMethodId: string) => { + setMethodId(nextMethodId); + setResult(null); + setRunError(null); + + const method = findMethod(nextMethodId); + setPayload(method ? formatPayloadTemplate(method.payloadTemplate) : ''); + }, []); + + const resetPayload = useCallback(() => { + if (!selectedMethod) return; + setPayload(formatPayloadTemplate(selectedMethod.payloadTemplate)); + setResult(null); + setRunError(null); + }, [selectedMethod]); + + const run = useCallback(async () => { + if (!selectedMethod) return; + + setIsRunning(true); + setResult(null); + setRunError(null); + + try { + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch (error) { + throw new Error(`Payload is not valid JSON — ${toMessage(error)}`); + } + + const response = await selectedMethod.invoke({ + cid: entity === 'channel' ? cid.trim() : undefined, + client: await getServerClient(), + payload: parsed, + }); + + setResult(JSON.stringify(response, null, 2)); + addNotification({ + duration: 4000, + emitter: serverSideClientEmitter, + incident: { + domain: 'api', + entity: selectedMethod.entity, + operation: selectedMethod.id, + status: 'success', + }, + message: `${selectedMethod.id} succeeded`, + severity: 'success', + targetPanels: ['modal'], + }); + } catch (error) { + const message = toMessage(error); + setRunError(message); + addNotification({ + // Stays until dismissed — a failure message is worth reading. + duration: 0, + emitter: serverSideClientEmitter, + error: error instanceof Error ? error : new Error(message), + incident: { + domain: 'api', + entity: selectedMethod.entity, + operation: selectedMethod.id, + status: 'failed', + }, + message: `${selectedMethod.id} failed — ${message}`, + severity: 'error', + targetPanels: ['modal'], + }); + } finally { + setIsRunning(false); + } + }, [addNotification, cid, entity, getServerClient, payload, selectedMethod]); + + const hasSecret = !!secret.trim(); + const needsCid = entity === 'channel'; + const canRun = + hasSecret && !!selectedMethod && !isRunning && (!needsCid || !!cid.trim()); + + return ( + + +
+

+ An API secret grants full admin access to the app. This dialog is a local + debugging aid — the secret stays in memory for as long as it is open and is + never persisted. Never put a secret in a production bundle. +

+ +
+ + + {/* Deliberately `type="text"` masked with `-webkit-text-security`, not a real + password field: 1Password decorates any `type="password"` input and its injected + UI steals focus on the first keystroke, which closes undocked Chrome DevTools. + The value is still never persisted. */} + setSecret(event.target.value)} + placeholder='Your Stream app secret' + spellCheck={false} + type='text' + value={secret} + /> + +
+ + {isChecking ? 'Checking…' : 'Check secret'} + + {secretCheck && ( + {secretCheck} + )} +
+

+ No connection is opened — a server-side client is stateless. The secret only + signs a {'{ "server": true }'} JWT attached to each REST call. + “Check secret” is optional; it just calls{' '} + getAppSettings so a wrong secret shows up here rather than on + Run. +

+
+ +
+ + + + + {needsCid && ( + + + + )} +
+ +
+ + + + +
+ +
+ + {selectedMethod?.targetsMember && ( + + +
+ + {isFetchingMembers ? 'Fetching…' : 'Fetch members'} + + + {memberOptions.length + ? `${memberOptions.length} member${memberOptions.length === 1 ? '' : 's'} listed` + : 'No members loaded yet'} + +
+ {memberError && ( +

{memberError}

+ )} +
+ )} +