/* eslint-disable */ // @ts-nocheck import { NetworkError, fetchWithError } from "@keycloak/keycloak-admin-client"; import type ClientRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientRepresentation"; import { HelpItem, KeycloakSelect, SelectVariant, } from "../../../../shared/keycloak-ui-shared"; import { ActionGroup, Alert, Button, Card, CardBody, CardHeader, CardTitle, FormGroup, Label, SelectOption, Text, TextContent, TextInput, } from "../../../../shared/@patternfly/react-core"; import debouncePromise from "p-debounce"; import { useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { useAdminClient } from "../../../admin-client"; import CodeEditor from "../../../components/form/CodeEditor"; import { FormAccess } from "../../../components/form/FormAccess"; import { useRealm } from "../../../context/realm-context/RealmContext"; import { addTrailingSlash } from "../../../util"; import { getAuthorizationHeaders } from "../../../utils/getAuthorizationHeaders"; import { toSsfClientTab } from "../../routes/ClientSsfTab"; import type { SsfClientStream } from "./StreamTab"; type SsfEmitResult = { status: string; jti?: string; }; type EmitSubjectType = "user-email" | "user-id" | "user-username" | "org-alias"; type EmitEventsFormValues = { emitEventType: string; emitSubjectType: EmitSubjectType; emitSubjectValue: string; emitPayload: string; }; /** * Replaces supported text-level placeholders in a raw JSON payload * before it's handed to {@code JSON.parse}. Today only {@code __now__} * is recognized; it expands to the current Unix time in seconds as a * bare integer literal, so it works for both unquoted numeric fields * ({@code "event_timestamp": __now__}) and quoted string fields * ({@code "some_field": "__now__"}). The expansion runs in plain text * so it sidesteps the JSON grammar — no need for a custom parser. */ const substitutePayloadPlaceholders = (raw: string): string => raw.replace(/__now__/g, String(Math.floor(Date.now() / 1000))); type EmitErrorBody = { error?: string; error_description?: string; params?: Record; }; const parseEmitErrorBody = (error: unknown): EmitErrorBody | null => { if (!(error instanceof NetworkError)) { return null; } const body = error.responseData; if (!body || typeof body !== "object") { return null; } return body as EmitErrorBody; }; export type EmitEventsTabProps = { client: ClientRepresentation; clientStream: SsfClientStream | null; nativelyEmittedEvents: string[]; }; export const EmitEventsTab = ({ client, clientStream, nativelyEmittedEvents, }: EmitEventsTabProps) => { const { t } = useTranslation(); const { adminClient } = useAdminClient(); const { realm } = useRealm(); // Constrain the emit dropdown to events_delivered — the // authoritative set the dispatcher will actually push to this // receiver. It's already the intersection of receiver-side // events_requested and what the realm registry supports, so // listing anything outside it would just produce filter-dropped // emissions. const emittableEvents = clientStream?.eventsDelivered ?? []; const { control, handleSubmit, watch, formState: { isSubmitting }, } = useForm({ defaultValues: { emitEventType: "", emitSubjectType: "user-email", emitSubjectValue: "", // Default to an empty JSON object with a newline between the // braces so the caret lands on an indented line ready for typing. emitPayload: "{\n \n}", }, mode: "onSubmit", }); // UI-only state for the typeahead/single selects and live feedback // panels — these aren't form values, so they stay in useState. const [emitEventTypeOpen, setEmitEventTypeOpen] = useState(false); const [emitEventTypeFilter, setEmitEventTypeFilter] = useState(""); const [emitSubjectTypeOpen, setEmitSubjectTypeOpen] = useState(false); // Live JSON parse-error message — updated on every change so the // operator sees the problem as they type rather than only after // hitting Emit. The submit handler also uses this to short-circuit // before the HTTP call. const [emitPayloadParseError, setEmitPayloadParseError] = useState< string | null >(null); const [emitResult, setEmitResult] = useState(null); const [emitError, setEmitError] = useState(null); // Watch the subject-type so the TextInput placeholder switches // from "user@example.com" to "user-uuid" / "username" / "org-alias" // without re-rendering the rest of the form. const emitSubjectType = watch("emitSubjectType"); // Debounce the live JSON parse so a fast typist doesn't pay // JSON.parse on every keystroke against a large payload. 250ms // matches the cadence at which the validation error feels // "instant" without flickering as the user types mid-token. const debouncedValidatePayload = useMemo( () => debouncePromise((value: string) => { try { JSON.parse(substitutePayloadPlaceholders(value)); setEmitPayloadParseError(null); } catch (error) { setEmitPayloadParseError( t("ssfEmitPayloadInvalidJson", { error: String(error) }), ); } }, 250), [t], ); /** * Emits a synthetic SSF event for this receiver via the admin-emit * endpoint. Builds an iss_sub subject from the realm issuer and the * provided userId so the admin only has to supply the user; the event * payload is pasted verbatim as JSON. On success, the result panel * shows a "Look up this event" link that navigates to the Event * Search tab with the returned jti pre-filled. */ const onSubmit = async (values: EmitEventsFormValues) => { if (!client.id) { return; } let parsedPayload: unknown; try { parsedPayload = values.emitPayload.trim() === "" ? {} : JSON.parse(substitutePayloadPlaceholders(values.emitPayload)); } catch (error) { const message = t("ssfEmitPayloadInvalidJson", { error: String(error) }); setEmitError(message); setEmitPayloadParseError(message); return; } setEmitError(null); try { // Send the (subjectType, subjectValue) shorthand — the admin // emit backend resolves it through the same path the // /subjects:add endpoint uses, then for user subjects builds // the sub_id via the receiver-format-aware mapper so the // resulting SET matches the shape native events for this // receiver would have. Org subjects produce a complex tenant- // only subject for org-scoped routing. const response = await fetchWithError( `${addTrailingSlash(adminClient.baseUrl)}admin/realms/${realm}/ssf/clients/${client.clientId}/events/emit`, { method: "POST", headers: { ...getAuthorizationHeaders(await adminClient.getAccessToken()), "Content-Type": "application/json", }, body: JSON.stringify({ eventType: values.emitEventType, subjectType: values.emitSubjectType, subjectValue: values.emitSubjectValue.trim(), event: parsedPayload, }), }, ); const result = (await response.json()) as SsfEmitResult; setEmitResult(result); } catch (error) { setEmitError(translateEmitError(error, values)); setEmitResult(null); } }; /** * Builds a translated, user-facing message from the failure response. * Switches on the server's machine-readable {@code error} code so the * "subject not found" case can render with the form's own * {@code subjectType}/{@code subjectValue} (avoiding a re-parse of * the server's English description). Unknown codes and non-NetworkError * exceptions fall through to {@code error_description} or the raw * thrown message. */ function translateEmitError( error: unknown, values: EmitEventsFormValues, ): string { const body = parseEmitErrorBody(error); const detail = body?.error_description ?? (error instanceof Error ? error.message : String(error)); switch (body?.error) { case "subject_not_found": // Prefer server-supplied params so the message reflects the // values that actually failed validation, not whatever the user // may have typed into the form while the request was in flight. return t("ssfEmitErrorSubjectNotFound", { type: body.params?.subjectType ?? values.emitSubjectType, value: body.params?.subjectValue ?? values.emitSubjectValue.trim(), }); case "invalid_request": return t("ssfEmitErrorInvalidRequest", { detail }); case "no_delivery_config": return t("ssfEmitErrorNoDeliveryConfig"); default: return t("ssfEmitErrorUnknown", { detail }); } } const eventSearchPath = (jti: string) => { // The route's :clientId path param is the client's internal UUID // (the parent client-details routes use it that way), not the OAuth // client_id. Use client.id; client.clientId would 404. const target = toSsfClientTab({ realm, clientId: client.id!, tab: "event-search", }); return { ...target, search: `?jti=${encodeURIComponent(jti)}`, }; }; return ( {t("ssfEmitTitle")} {t("ssfEmitTitleHelp")} } isRequired > ( { field.onChange(value.toString()); setEmitEventTypeOpen(false); setEmitEventTypeFilter(""); }} onClear={() => { field.onChange(""); setEmitEventTypeFilter(""); }} onFilter={setEmitEventTypeFilter} > {emittableEvents .filter((eventType) => eventType .toLowerCase() .includes(emitEventTypeFilter.toLowerCase()), ) .map((eventType) => ( {eventType} {nativelyEmittedEvents.includes(eventType) && ( )} ))} )} /> ( { field.onChange(value as EmitSubjectType); setEmitSubjectTypeOpen(false); }} > {t("ssfSubjectType.userEmail")} {t("ssfSubjectType.userId")} {t("ssfSubjectType.userUsername")} {t("ssfSubjectType.orgAlias")} )} /> } isRequired > value.trim() !== "" || t("ssfEmitSubjectValueRequired"), }} render={({ field }) => ( field.onChange(value)} placeholder={ emitSubjectType === "user-email" ? "user@example.com" : emitSubjectType === "user-id" ? "user-uuid" : emitSubjectType === "user-username" ? "username" : "org-alias" } /> )} /> } > ( { field.onChange(value); // Live validation: substitute placeholders first // (so unquoted __now__ becomes a valid numeric // literal) then JSON.parse. Blank payload resolves // to {} at submit time and is therefore valid — // clear the error synchronously so the UI feedback // is immediate when the user empties the field. if (value.trim() === "") { setEmitPayloadParseError(null); return; } void debouncedValidatePayload(value); }} /> )} /> {emitPayloadParseError && ( {emitPayloadParseError} )} {emitError && ( )} {emitResult && ( {t("ssfEmitResult", { status: emitResult.status, jti: emitResult.jti, })} {emitResult.jti && ( {t("ssfEmitResultLookupLink")} )} )} ); };