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}

+ )} +
+ )} +