Build your own UI
This page shows how a UI of your own calls the IdentityFlow 0.3.0 GraphQL API. It lists the dialogs waiting for the signed-in account, shows and answers one, and starts a workflow. Every query root, mutation and error code is listed in the GraphQL API reference.
IdentityFlow 0.3.0 accepts one kind of credential at /graphql: the
identity_flow_session cookie that its OIDC sign-in sets in the browser.
0.3.0 has no token or API key for backend services or scripts that run
without a browser session.
0.3.0 is built for a UI served from the same origin as IdentityFlow, the
origin of auth.oidc.redirectUri in the
configuration. If your UI is a separate web
server, put it behind the same reverse proxy as IdentityFlow, under a path
prefix IdentityFlow does not use, such as /app/. IdentityFlow uses /
(which redirects to /tasks), /tasks, /my, /instances, /workflows,
/access, /analytics, /dead-letters, /auth/, /graphql, /-hooks/,
/_app/ and /favicon.png.
Everything served from that origin acts with the signed-in account’s
authority. The cookie’s path is /, so your UI’s server receives the session
cookie with every request, and any script on the origin can call the API.
Serve only code you trust there, and do not log request cookies.
The cookie is HttpOnly and SameSite=Lax: your code never reads it, and
browsers do not send it with requests from other sites. They do send it with
requests from pages on the same site, such as another subdomain of the same
domain or another port on the same host. IdentityFlow 0.3.0 answers those
requests, including their CORS preflight, for any origin, so such a page can
read data and run commands as the signed-in account. Do not serve content you
do not control on the same site as IdentityFlow.
To sign someone in, send the browser to /auth/login. Add
?returnTo=/your/path to come back to a page of your UI afterwards; the path
must be on the same origin. A session lasts auth.oidc.sessionTtl, at most 8
hours, and is not extended. After that every call fails with
UNAUTHENTICATED, and your UI sends the browser to /auth/login again. To sign
out, post a same-origin form to /auth/logout.
The account that signed in is the actor for every call. IdentityFlow checks its access on each read and each command; your UI does not enforce access rules itself.
All examples on this page use this helper. It posts the query and its
variables to /graphql and turns an entry in errors[] into an exception with
the error code.
export class GraphQLRequestError extends Error { readonly code: string;
constructor(message: string, code: string) { super(message); this.name = 'GraphQLRequestError'; this.code = code; }}
interface GraphQLResponse<Data> { readonly data?: Data | null; readonly errors?: readonly { readonly message: string; readonly extensions?: { readonly code?: string }; }[];}
export async function graphql<Data>( query: string, variables: Record<string, unknown> = {},): Promise<Data> { const response = await fetch('/graphql', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ query, variables }), }); // IdentityFlow answers this request with HTTP 200, errors included. // Another status means the request itself was malformed or never reached it. if (!response.ok) { throw new GraphQLRequestError(`HTTP ${response.status}`, 'HTTP_ERROR'); } const body = (await response.json()) as GraphQLResponse<Data>; const [error] = body.errors ?? []; if (error) { throw new GraphQLRequestError(error.message, error.extensions?.code ?? 'UNKNOWN'); } if (!body.data) { throw new GraphQLRequestError('The response has no data', 'UNKNOWN'); } return body.data;}For Go developers: async function … : Promise<Data> returns a value you
await, much like a call that blocks until the result arrives. <Data> is a
type parameter, like a Go generic.
IdentityFlow answers a request like this one with HTTP 200, errors included.
The error sits in errors[], and data is null when a field the response
needs failed, so check errors[] on every response. A body that is not valid
JSON gets 400; the reference lists the other
cases.
assignedDialogs lists the pending dialogs the signed-in account may answer:
those whose assignees include one of the account’s
principals, the { providerId, subject } pairs its
identity provider reports for it.
export const assignedDialogsQuery = /* GraphQL */ ` query AssignedDialogs { assignedDialogs { list(first: 100) { items { id instanceId workflowName name message dueAt createdAt } } } }`;
export interface DialogSummary { readonly id: string; readonly instanceId: string; readonly workflowName: string; readonly name: string; readonly message: string | null; readonly dueAt: string | null; readonly createdAt: string;}
export async function listAssignedDialogs(): Promise<readonly DialogSummary[]> { const data = await graphql<{ assignedDialogs: { list: { items: DialogSummary[] } } }>( assignedDialogsQuery, ); return data.assignedDialogs.list.items;}Request only items. The list types also declare total, facets and
pageInfo, but in 0.3.0 requesting any of them makes the whole query fail with
INTERNAL_SERVER_ERROR. Without pageInfo there is no cursor to pass to
after, so a list returns at most the first 100 items, the maximum for
first.
assignedDialogs.find returns one dialog with its taskContext, the data the
dialog carries for the person who answers it.
export const dialogQuery = /* GraphQL */ ` query Dialog($id: ID!) { assignedDialogs { find(id: $id) { id instanceId workflowName name message dueAt taskContext } } }`;
export interface DialogDetail extends Omit<DialogSummary, 'createdAt'> { readonly taskContext: unknown;}
export async function findDialog(id: string): Promise<DialogDetail> { const data = await graphql<{ assignedDialogs: { find: DialogDetail } }>(dialogQuery, { id }); return data.assignedDialogs.find;}Once someone has answered the dialog, it is no longer pending, and find
returns NOT_FOUND. Remove it from your list and reload.
completeDialog answers the dialog with status: COMPLETED and the answer in
data.
export const completeDialogMutation = /* GraphQL */ ` mutation CompleteDialog($input: CompleteDialogInput!) { completeDialog(input: $input) { dialogId instanceId status decidedAt } }`;
export interface DialogCompletion { readonly dialogId: string; readonly instanceId: string; readonly status: 'COMPLETED' | 'ERRORED'; readonly decidedAt: string;}
/** Validate `answer` against the dialog's schema before you call this. */export async function answerDialog(dialogId: string, answer: unknown): Promise<DialogCompletion> { const data = await graphql<{ completeDialog: DialogCompletion }>(completeDialogMutation, { input: { dialogId, status: 'COMPLETED', data: answer }, }); return data.completeDialog;}IdentityFlow checks that the account holds one of the dialog’s assignee principals at the moment it submits, that the dialog is still pending, and that the instance, the run of the workflow, is active or paused. It asks the account’s provider, the identity hook that reports its principals, for the current principals on every command, so a change in your identity system takes effect on the next answer. If the provider does not answer, the command is refused.
Validate the answer before you submit it. IdentityFlow checks the answer
against the dialog’s schema only when the workflow continues, after the
mutation has returned. An answer that does not match is reported as
COMPLETED; then the dialog step and the instance become ERRORED, and
reading the instance’s businessData fails from then on. The GraphQL API in
0.3.0 does not return the dialog’s answer schema, so validate against the
schema declared in the Workflow Definition.
status: ERRORED is the other outcome. Pass the error in data: the dialog
step fails with it, and the step’s retry policy decides what happens next.
startCatalog lists the Workflow Definitions the account
may start. find adds inputContract, a JSON Schema description of the parameters derived from
the definition’s schema; Build a start form from the input schema shows how
to write a schema that makes a good form. inputContract is null when the
definition has no schema or its schema is not a Valibot schema.
export const startCatalogQuery = /* GraphQL */ ` query StartCatalog { startCatalog { list(first: 100) { items { id name version releaseChannel label } } } }`;
export const startableWorkflowQuery = /* GraphQL */ ` query StartableWorkflow($id: ID!) { startCatalog { find(id: $id) { id name version description inputContract } } }`;Pass the id of a catalog item as workflow to startWorkflow:
export const startWorkflowMutation = /* GraphQL */ ` mutation StartWorkflow($input: StartWorkflowInput!) { startWorkflow(input: $input) { id workflowName workflowVersion status createdAt } }`;
export interface StartedInstance { readonly id: string; readonly workflowName: string; readonly workflowVersion: string; readonly status: 'ACTIVE' | 'PAUSED' | 'TERMINATED' | 'COMPLETED' | 'ERRORED'; readonly createdAt: string;}
/** `workflowId` is the `id` of an item from the start catalog. */export async function startWorkflow(workflowId: string, params: unknown): Promise<StartedInstance> { const data = await graphql<{ startWorkflow: StartedInstance }>(startWorkflowMutation, { input: { workflow: workflowId, params }, }); return data.startWorkflow;}IdentityFlow checks params against the definition’s schema before it creates
the instance. Parameters that do not match are refused, and no instance is
created.
Reads return only what the signed-in account may see. The access check runs
inside the database query, before sorting and paging, so a page contains only
records the account may see, and first: 100 returns up to 100 of them. A
view the account has no access to returns an empty list, not an error.
find returns NOT_FOUND with the message Resource not found both for a
record that does not exist and for one the account may not see. Your UI cannot
tell the two apart.
The read views table in the reference lists the access behind each view, and the authorization boundary describes how reads and commands are checked.
An unauthenticated call, for example after the session expired, returns:
{ "errors": [ { "message": "Authentication required", "locations": [{ "line": 1, "column": 3 }], "path": ["assignedDialogs"], "extensions": { "code": "UNAUTHENTICATED" } } ], "data": null}A refused command returns Unexpected error. with INTERNAL_SERVER_ERROR.
This response came from completeDialog sent by an account that is not an
assignee:
{ "errors": [ { "message": "Unexpected error.", "locations": [{ "line": 1, "column": 42 }], "path": ["completeDialog"], "extensions": { "code": "INTERNAL_SERVER_ERROR" } } ], "data": null}In 0.3.0 every refused command looks like this: no access, not an assignee, an
unknown ID, parameters that do not match, or an instance in the wrong state.
The reason is only in the IdentityFlow server log, as NotFoundError,
ForbiddenError, ValidationError, ConflictError, or AuthorizationError
when the provider could not report the account’s principals.
extensions.code | What your UI does |
|---|---|
UNAUTHENTICATED | Send the browser to /auth/login?returnTo=…. |
NOT_FOUND | Treat the record as gone; reload the list. |
BAD_USER_INPUT | Fix the request. An argument is malformed, such as an ID or first above 100. |
INTERNAL_SERVER_ERROR | Reload the record and show that the action failed. An operator finds the reason in the log. |
GRAPHQL_VALIDATION_FAILED, GRAPHQL_PARSE_FAILED, or no code | Fix the query. It does not match the schema, is not valid GraphQL, or exceeds a query limit. |
- 0.3.0 has no credential for clients without a browser session.
- Lists return at most the first 100 items;
total,facetsandpageInfofail the query. - Refused commands all arrive as
INTERNAL_SERVER_ERROR. - An answer that does not match the dialog’s schema is accepted and then errors the step.
- The API does not return a dialog’s answer schema.
- 0.3.0 has no subscriptions; reload the list to see new dialogs.
- 0.3.0 has no REST API.