/* eslint-disable */ // @ts-nocheck import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; import { HelpItem, KeycloakSelect, ListEmptyState, PasswordInput, SelectVariant, useAlerts, } from "../../../../shared/keycloak-ui-shared"; import { ActionGroup, AlertVariant, Button, ButtonVariant, Card, CardBody, CardHeader, CardTitle, FormGroup, FormHelperText, HelperText, HelperTextItem, InputGroup, InputGroupItem, Label, SelectOption, Text, TextContent, TextInput, } from "../../../../shared/@patternfly/react-core"; import { CheckCircleIcon, InfoCircleIcon, PauseCircleIcon, SyncAltIcon, TimesCircleIcon, } from "../../../../shared/@patternfly/react-icons"; import { useEffect, useRef, useState } from "react"; import { useFormContext, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useAdminClient } from "../../../admin-client"; import { useConfirmDialog } from "../../../components/confirm-dialog/ConfirmDialog"; import { CopyToClipboardButton } from "../../../components/copy-to-clipboard-button/CopyToClipboardButton"; import { FormAccess } from "../../../components/form/FormAccess"; import { useRealm } from "../../../context/realm-context/RealmContext"; import { addTrailingSlash, convertAttributeNameToForm } from "../../../util"; import { getAuthorizationHeaders } from "../../../utils/getAuthorizationHeaders"; import useFormatDate from "../../../utils/useFormatDate"; import type { FormFields } from "../../ClientDetails"; const DELIVERY_METHOD_PUSH_URI = "urn:ietf:rfc:8935"; const DELIVERY_METHOD_POLL_URI = "urn:ietf:rfc:8936"; const isPollDeliveryMethod = (method: string | undefined): boolean => method === DELIVERY_METHOD_POLL_URI || method === "https://schemas.openid.net/secevent/risc/delivery-method/poll"; /** * Order-insensitive equality for the events_requested / * events_delivered string lists. Used to drive the "dirty" indicator * on the admin-side stream edit form so toggling a multi-select item * back to its original state doesn't keep the Save button enabled. */ const arraysEqualUnordered = (a: string[], b: string[]): boolean => { if (a === b) return true; if (a.length !== b.length) return false; const aSorted = [...a].sort(); const bSorted = [...b].sort(); for (let i = 0; i < aSorted.length; i++) { if (aSorted[i] !== bSorted[i]) return false; } return true; }; /** * Best-effort URL validation for the receiver's push endpoint. Uses * the URL constructor (no regex juggling) and additionally requires * an http/https protocol — the SSF dispatcher only knows how to push * over HTTP, so accepting e.g. ftp:// or javascript: would just * dead-letter the queued events on first push attempt. An empty * string is treated as "not yet typed" and not validated here so the * field's existing isRequired check owns that case. */ const isValidPushEndpointUrl = (value: string): boolean => { const trimmed = value.trim(); if (trimmed === "") return true; try { const parsed = new URL(trimmed); return parsed.protocol === "http:" || parsed.protocol === "https:"; } catch { return false; } }; /** * Builds a human-readable message from a failed SSF endpoint response. * The transmitter returns OAuth2-style error bodies * ({ error, error_description }), so surface error_description * rather than the raw JSON. Falls back to the raw body, then to the HTTP status line. */ const ssfErrorMessage = async (response: Response): Promise => { const text = await response.text(); if (text) { try { const body = JSON.parse(text) as { error_description?: string; error?: string; }; const message = body.error_description || body.error; if (message) return message; } catch { // Body wasn't JSON — fall through to the raw text. } return text; } return `${response.status} ${response.statusText || "Request failed"}`; }; type SsfClientStreamDelivery = { method?: string; endpoint_url?: string; authorization_header?: string; }; export type SsfStreamManagedBy = "RECEIVER" | "KEYCLOAK"; export type SsfClientStream = { streamId?: string; description?: string; status?: string; statusReason?: string; audience?: string[]; delivery?: SsfClientStreamDelivery; eventsSupported?: string[]; eventsRequested?: string[]; eventsDelivered?: string[]; createdAt?: number; updatedAt?: number; lastVerifiedAt?: number; managedBy?: SsfStreamManagedBy; }; export type StreamTabProps = { client: ClientRepresentation; clientStream: SsfClientStream | null; setClientStream: (stream: SsfClientStream | null) => void; defaultSupportedEvents: string; nativelyEmittedEvents: string[]; refresh: () => void; }; export const StreamTab = ({ client, clientStream, setClientStream, defaultSupportedEvents, nativelyEmittedEvents, refresh, }: StreamTabProps) => { const { t } = useTranslation(); const { adminClient } = useAdminClient(); const { realm } = useRealm(); const { control, setValue } = useFormContext(); const { addAlert, addError } = useAlerts(); const formatDate = useFormatDate(); // The Events Requested dropdown on the create-stream form should // surface only events the receiver has marked as Supported, not the // full transmitter-wide registry. Watch the form value so the list // reflects unsaved Receiver-tab edits without a round-trip; fall // back to the realm default when the receiver hasn't customised it. const ssfSupportedEvents = useWatch({ control, name: convertAttributeNameToForm( "attributes.ssf.supportedEvents", ) as never, }) as string | undefined; const receiverSupportedEvents = (ssfSupportedEvents ?? defaultSupportedEvents) .split(",") .map((s) => s.trim()) .filter((s) => s.length > 0) // Storage order is whatever the operator picked when configuring the // receiver — sort here so the create-stream dropdown is alphabetical // and matches the deterministic order the backend now returns for // availableSupportedEvents. .sort((a, b) => a.localeCompare(b)); // Admin-side create-stream form state — used by the empty-state form // when no stream is registered yet. Kept as plain useState because the // surrounding react-hook-form context is bound to the client // representation's attributes, not to stream-create parameters. const [createStreamMethod, setCreateStreamMethod] = useState<"PUSH" | "POLL">( "PUSH", ); const [createStreamEndpointUrl, setCreateStreamEndpointUrl] = useState(""); const [createStreamAuthHeader, setCreateStreamAuthHeader] = useState(""); const [createStreamEvents, setCreateStreamEvents] = useState([]); const [createStreamEventsFilter, setCreateStreamEventsFilter] = useState(""); const [createStreamProfile, setCreateStreamProfile] = useState< "SSF_1_0" | "SSE_CAEP" >(client.attributes?.["ssf.profile"] as "SSF_1_0" | "SSE_CAEP"); const [createStreamDescription, setCreateStreamDescription] = useState(""); const [createStreamEventsOpen, setCreateStreamEventsOpen] = useState(false); const [createStreamSubmitting, setCreateStreamSubmitting] = useState(false); const [createStreamFormOpen, setCreateStreamFormOpen] = useState(false); const [statusActionLoading, setStatusActionLoading] = useState(false); // ---- admin-side stream edit state --------------------------------- // Tracks pending edits on the registered stream's admin-editable // fields. Initialised from the loaded stream and reset when the // stream refreshes so a refresh-without-save discards local edits. const [editStreamDescription, setEditStreamDescription] = useState(""); const [editStreamEventsRequested, setEditStreamEventsRequested] = useState< string[] >([]); const [editStreamEventsDelivered, setEditStreamEventsDelivered] = useState< string[] >([]); const [editEventsRequestedOpen, setEditEventsRequestedOpen] = useState(false); const [editEventsDeliveredOpen, setEditEventsDeliveredOpen] = useState(false); const [streamEditSubmitting, setStreamEditSubmitting] = useState(false); // Description / events_requested / events_delivered are receiver- // owned for streams the receiver registered itself (via the // standard SSF /streams endpoint). We surface them as read-only on // the admin Stream tab in that mode so an admin save can't silently // overwrite the receiver's contract; the server PATCH endpoint // stays open for operators who need to override out-of-band, but // the UI keeps that out of the happy path. const canEditStream = clientStream?.managedBy === "KEYCLOAK"; const streamEditDirty = canEditStream && ((clientStream.description ?? "") !== editStreamDescription || !arraysEqualUnordered( clientStream.eventsRequested ?? [], editStreamEventsRequested, ) || !arraysEqualUnordered( clientStream.eventsDelivered ?? [], editStreamEventsDelivered, )); // Tracks the streamId the edit fields were last seeded from, so we can // tell "a different stream just loaded" (always re-seed) apart from "the // admin is editing the stream that's already loaded" (don't clobber). const seededStreamIdRef = useRef(undefined); // Re-seed edit state when the loaded stream changes — but never clobber // unsaved admin edits to the stream that's already loaded. // // The dirty guard alone deadlocks on first load of a KEYCLOAK-managed // stream: the edit fields start empty, so the moment the stream loads with // a non-empty description / events the state looks "dirty" against that // empty seed and the guard refuses to seed it — leaving the fields blank // forever. Gating the guard on "same stream as last seeded" breaks the // cycle: a freshly created / newly loaded stream has no local edits to // protect, so it always seeds. useEffect(() => { const loadedStreamId = clientStream?.streamId; const sameStream = seededStreamIdRef.current === loadedStreamId; if (sameStream && streamEditDirty) { return; } seededStreamIdRef.current = loadedStreamId; setEditStreamDescription(clientStream?.description ?? ""); setEditStreamEventsRequested(clientStream?.eventsRequested ?? []); setEditStreamEventsDelivered(clientStream?.eventsDelivered ?? []); }, [ clientStream?.streamId, clientStream?.description, clientStream?.eventsRequested, clientStream?.eventsDelivered, streamEditDirty, ]); const submitStreamEdit = async () => { if (!client.clientId || !clientStream || !streamEditDirty) { return; } setStreamEditSubmitting(true); try { const body: Record = {}; if ((clientStream.description ?? "") !== editStreamDescription) { body.description = editStreamDescription; } if ( !arraysEqualUnordered( clientStream.eventsRequested ?? [], editStreamEventsRequested, ) ) { body.events_requested = editStreamEventsRequested; } if ( !arraysEqualUnordered( clientStream.eventsDelivered ?? [], editStreamEventsDelivered, ) ) { body.events_delivered = editStreamEventsDelivered; } const response = await fetch( `${addTrailingSlash( adminClient.baseUrl, )}admin/realms/${realm}/ssf/clients/${client.clientId}/stream`, { method: "PATCH", headers: { ...getAuthorizationHeaders(await adminClient.getAccessToken()), "Content-Type": "application/json", }, body: JSON.stringify(body), }, ); if (!response.ok) { throw new Error(await ssfErrorMessage(response)); } addAlert(t("ssfStreamUpdateSuccess"), AlertVariant.success); refresh(); } catch (error) { addError("ssfStreamUpdateError", error); } finally { setStreamEditSubmitting(false); } }; const discardStreamEdit = () => { setEditStreamDescription(clientStream?.description ?? ""); setEditStreamEventsRequested(clientStream?.eventsRequested ?? []); setEditStreamEventsDelivered(clientStream?.eventsDelivered ?? []); }; /** * Drives the admin stream-status endpoint * (POST /admin/realms/{realm}/ssf/clients/{clientId}/stream/status). * The backend funnels through StreamService.updateStreamStatus, so * this triggers the spec-mandated stream-updated SET dispatch and * the outbox HELD ↔ PENDING alignment that a generic client-save * doesn't. */ const triggerStreamStatusUpdate = async ( targetStatus: "enabled" | "paused" | "disabled", ) => { if (!client.id || !clientStream?.streamId) { return; } setStatusActionLoading(true); try { const response = await fetch( `${addTrailingSlash( adminClient.baseUrl, )}admin/realms/${realm}/ssf/clients/${client.clientId}/stream/status`, { method: "POST", headers: { ...getAuthorizationHeaders(await adminClient.getAccessToken()), "Content-Type": "application/json", }, body: JSON.stringify({ stream_id: clientStream.streamId, status: targetStatus, }), }, ); if (!response.ok) { throw new Error(await ssfErrorMessage(response)); } // Keep the form-bound status field in sync immediately so a // subsequent generic Save doesn't clobber the just-applied // backend status with a stale form value (the parent's // useFetch refresh below is async and races the click). setValue( convertAttributeNameToForm("attributes.ssf.stream.status"), targetStatus, { shouldDirty: false }, ); addAlert(t("ssfStreamStatusUpdateSuccess"), AlertVariant.success); // Refresh so the read-only status indicator + lastVerifiedAt / // updatedAt in the UI reflect the new state. refresh(); } catch (error) { addError("ssfStreamStatusUpdateError", error); } finally { setStatusActionLoading(false); } }; const triggerVerifyStream = async () => { if (!client.id) { return; } try { const response = await fetch( `${addTrailingSlash( adminClient.baseUrl, )}admin/realms/${realm}/ssf/clients/${client.clientId}/stream/verify`, { method: "POST", headers: getAuthorizationHeaders(await adminClient.getAccessToken()), }, ); if (!response.ok) { throw new Error( `${response.status} ${response.statusText || "Request failed"}`, ); } addAlert(t("ssfVerifyStreamSuccess"), AlertVariant.success); // Refetch the stream endpoint so the Last verified field in the UI // picks up the new timestamp the backend just stamped. refresh(); } catch (error) { addError("ssfVerifyStreamError", error); } }; const resetCreateStreamForm = () => { setCreateStreamEndpointUrl(""); setCreateStreamAuthHeader(""); setCreateStreamEvents([]); setCreateStreamDescription(""); setCreateStreamMethod("PUSH"); }; // Prefill events_requested with the receiver's Supported Events on // open. The vast majority of streams want every event the receiver // declared support for; the operator can still narrow the selection // before submitting. Seeded fresh each open so unsaved Receiver-tab // edits flow through. const openCreateStreamForm = () => { setCreateStreamEvents([...receiverSupportedEvents]); setCreateStreamFormOpen(true); }; const submitCreateStream = async () => { if (!client.id) { return; } // PUSH requires the receiver to supply its own endpoint URL; POLL // doesn't (the transmitter generates the URL itself per SSF §6.1.2 // and writes it back on the response). if (createStreamMethod === "PUSH" && !createStreamEndpointUrl.trim()) { addError( "ssfCreateStreamError", new Error(t("ssfCreateStreamEndpointUrlRequired")), ); return; } if ( createStreamMethod === "PUSH" && !isValidPushEndpointUrl(createStreamEndpointUrl) ) { addError( "ssfCreateStreamError", new Error(t("ssfCreateStreamEndpointUrlInvalid")), ); return; } setCreateStreamSubmitting(true); try { // SSF profile is a per-receiver attribute; the create-stream // backend reads it from the client (resolveReceiverProfile). // If the operator picked a different profile in the create // form, persist the receiver attribute first so the subsequent // create-stream call sees the new value. Two API calls instead // of one, but the side effect is OK — profile is a one-time // decision per receiver, and the create form is precisely // where you'd be making it the first time. const savedProfile = client.attributes?.["ssf.profile"] || "SSF_1_0"; if (createStreamProfile !== savedProfile) { await adminClient.clients.update( { id: client.id! }, { ...client, attributes: { ...(client.attributes ?? {}), "ssf.profile": createStreamProfile, }, }, ); } // Event aliases stored in local state are resolved back to their // canonical URIs by the transmitter at create time — the admin UI // shows/stores aliases because that's what the admin picks from // the receiver's supported-events list, and the backend accepts // either form. const delivery: Record = { method: createStreamMethod === "POLL" ? DELIVERY_METHOD_POLL_URI : DELIVERY_METHOD_PUSH_URI, }; if (createStreamMethod === "PUSH") { delivery.endpoint_url = createStreamEndpointUrl.trim(); if (createStreamAuthHeader.trim()) { delivery.authorization_header = createStreamAuthHeader.trim(); } } const body: Record = { delivery }; if (createStreamEvents.length > 0) { body.events_requested = createStreamEvents; } if (createStreamDescription.trim()) { body.description = createStreamDescription.trim(); } const response = await fetch( `${addTrailingSlash( adminClient.baseUrl, )}admin/realms/${realm}/ssf/clients/${client.clientId}/stream`, { method: "POST", headers: { ...getAuthorizationHeaders(await adminClient.getAccessToken()), "Content-Type": "application/json", }, body: JSON.stringify(body), }, ); if (!response.ok) { throw new Error(await ssfErrorMessage(response)); } addAlert(t("ssfCreateStreamSuccess"), AlertVariant.success); resetCreateStreamForm(); setCreateStreamFormOpen(false); refresh(); } catch (error) { addError("ssfCreateStreamError", error); } finally { setCreateStreamSubmitting(false); } }; const [toggleDeleteStreamDialog, DeleteStreamConfirm] = useConfirmDialog({ titleKey: "ssfDeleteStreamConfirmTitle", messageKey: "ssfDeleteStreamConfirmMessage", continueButtonLabel: "delete", continueButtonVariant: ButtonVariant.danger, onConfirm: async () => { if (!client.id) { return; } try { const response = await fetch( `${addTrailingSlash( adminClient.baseUrl, )}admin/realms/${realm}/ssf/clients/${client.clientId}/stream`, { method: "DELETE", headers: getAuthorizationHeaders( await adminClient.getAccessToken(), ), }, ); if (!response.ok) { throw new Error( `${response.status} ${response.statusText || "Request failed"}`, ); } addAlert(t("ssfDeleteStreamSuccess"), AlertVariant.success); setClientStream(null); refresh(); } catch (error) { addError("ssfDeleteStreamError", error); } }, }); return ( <> {clientStream && ( <> {t("ssfStream")} {t("ssfStreamHelp")} )} {clientStream && ( )} {!clientStream ? ( <> {!createStreamFormOpen && ( )} {createStreamFormOpen && ( } > } > {createStreamMethod === "PUSH" && ( <> } > setCreateStreamEndpointUrl(value) } /> {!isValidPushEndpointUrl(createStreamEndpointUrl) && ( {t("ssfCreateStreamEndpointUrlInvalid")} )} } > setCreateStreamAuthHeader( (event.target as HTMLInputElement).value, ) } /> )} } > { const option = value.toString(); if (!option) return; setCreateStreamEvents((current) => current.includes(option) ? current.filter((e) => e !== option) : [...current, option], ); setCreateStreamEventsFilter(""); }} onClear={() => { setCreateStreamEvents([]); setCreateStreamEventsFilter(""); }} onFilter={setCreateStreamEventsFilter} > {receiverSupportedEvents .filter((event) => event .toLowerCase() .includes(createStreamEventsFilter.toLowerCase()), ) .map((event) => ( {event} {nativelyEmittedEvents.includes(event) && ( )} ))} } > setCreateStreamDescription(value) } /> )} ) : ( } > } > setEditStreamDescription(value) : undefined } /> {clientStream.createdAt && ( } > )} {clientStream.updatedAt && ( } > )} } > } > 0 ? clientStream.audience.join(", ") : "" } /> } > {clientStream.delivery?.endpoint_url && ( } > )} {/* Push auth header is irrelevant for POLL — receivers authenticate themselves with their own bearer token to call the transmitter-hosted poll endpoint, and the transmitter doesn't store an outbound auth header for POLL streams. */} {clientStream.delivery?.endpoint_url && !isPollDeliveryMethod(clientStream.delivery.method) && ( } > )} } > {clientStream.status === "enabled" && ( )} {clientStream.status === "paused" && ( )} {clientStream.status === "disabled" && ( )} {!clientStream.status && ( )} {clientStream.status !== "enabled" && ( )} {clientStream.status !== "paused" && ( )} {clientStream.status !== "disabled" && ( )} } > } > setEditEventsRequestedOpen(open)} isOpen={editEventsRequestedOpen} selections={editStreamEventsRequested} onSelect={(value) => { const event = value as string; setEditStreamEventsRequested((prev) => prev.includes(event) ? prev.filter((e) => e !== event) : [...prev, event], ); }} onClear={ canEditStream ? () => setEditStreamEventsRequested([]) : undefined } > {receiverSupportedEvents.map((event) => ( {event} ))} } > setEditEventsDeliveredOpen(open)} isOpen={editEventsDeliveredOpen} selections={editStreamEventsDelivered} onSelect={(value) => { const event = value as string; setEditStreamEventsDelivered((prev) => prev.includes(event) ? prev.filter((e) => e !== event) : [...prev, event], ); }} onClear={ canEditStream ? () => setEditStreamEventsDelivered([]) : undefined } > {receiverSupportedEvents.map((event) => ( {event} ))} {canEditStream && ( <> )} )} ); };