A small support inbox that teammates sign in to with their Plain account. Once signed in you can:
- browse your workspace's thread queue and filter it by status, assignee and priority
- open a thread and read its timeline
- reply to the customer
- mark the thread as done
It runs entirely in the browser. There is no backend: the app does the OAuth sign-in itself and calls Plain's GraphQL API directly with the signed-in person's access token. Every call runs as that person, with the permissions their role in Plain already has.
The point of the example is the Plain parts: OAuth and the API calls. The rest is a deliberately ordinary Vite + React app with few dependencies:
oidc-client-tsandreact-oidc-contextfor OAuthreact-routerfor routing- CSS Modules for styling
You need Node 22.22 or later and a Plain workspace.
In Plain, go to Settings → OAuth applications and click Add OAuth app.
- App name: shown to people when they sign in, for example "Support inbox".
- Redirect URLs:
http://localhost:5173/callback. Add your production URL too when you deploy, for examplehttps://inbox.example.com/callback.
Make it a public client, which uses PKCE and has no client secret. A browser app can't keep a secret, so this example never uses one.
Copy the client ID.
cp .env.example .env.local
# Set VITE_PLAIN_OAUTH_CLIENT_ID in .env.local
npm install
npm run devOpen http://localhost:5173 and click Sign in with Plain.
| Variable | Required | Purpose |
|---|---|---|
VITE_PLAIN_OAUTH_CLIENT_ID |
Yes | Your OAuth app's client ID. |
VITE_PLAIN_API_URL |
No | GraphQL endpoint. Defaults to https://core-api.uk.plain.com/graphql/v1. |
VITE_PLAIN_AUTH_URL |
No | OAuth authorization server. Defaults to https://signin.auth.plain.com. |
VITE_PLAIN_APP_URL |
No | Plain's web app, used for "Open in Plain" links. Defaults to https://app.plain.com. |
Vite bundles every VITE_ variable into the JavaScript it sends to the browser, so treat them all as public. A client ID is public by design; never put a client secret or API key in a VITE_ variable.
| File | What it shows |
|---|---|
src/auth.ts |
The whole OAuth configuration: authority, client ID, redirect URL, scopes, refresh, nonce, and clearing a sign-in that bounced or has no workspace. |
src/main.tsx |
AuthProvider wiring. It completes the sign-in when Plain redirects back. |
src/routes/RequireAuth.tsx |
Protecting pages, and refreshing a token that expired while the tab was asleep. |
src/components/AppShell.tsx |
Loading the signed-in person and workspace with myUser and myWorkspace, and signing out. |
src/plain/client.ts |
plainRequest(), the only function that calls the API, and how query errors are handled. |
src/plain/operations.ts |
Every GraphQL query and mutation the app uses, with TypeScript types for each. |
src/plain/hooks.ts |
Passing the access token to each call, and signing out when Plain rejects it. |
src/routes/Queue.tsx |
Turning URL filters into Plain's ThreadsFilter, and cursor pagination. |
src/routes/Thread.tsx |
Loading a timeline, replying, marking as done, and showing mutation errors. |
The app uses the OAuth 2.0 authorization code flow with PKCE, against Plain's authorization server at https://signin.auth.plain.com. The server's settings are in its discovery document.
sequenceDiagram
participant App as Browser app
participant Auth as signin.auth.plain.com
participant API as core-api.uk.plain.com
App->>App: create state, nonce and PKCE verifier
App->>Auth: redirect to /oauth2/authorize
Auth->>App: redirect to /callback?code=...&state=...
App->>Auth: POST /oauth2/token (code + PKCE verifier, no secret)
Auth->>App: access_token, refresh_token, id_token
App->>API: POST /graphql/v1 with Authorization: Bearer access_token
API->>App: data, as the signed-in person
App->>Auth: POST /oauth2/token (refresh_token) before the access token expires
- Sign in. "Sign in with Plain" calls
signinRedirect().oidc-client-tscreates a randomstateand a PKCE code verifier, and stores both insessionStorage.- It sends the browser to
/oauth2/authorizewithscope=openid offline_access.offline_accessis what gets you a refresh token. - Plain requires a
nonce, which we generate withcrypto.randomUUID(). - Plain's sign-in screen asks which workspace to use, but it does not require an answer. Continuing without one issues a token that has no workspace.
- Callback. Plain redirects back to
/callback?code=...&state=....AuthProviderchecksstateand exchanges the code at/oauth2/tokenwith the PKCE verifier. No client secret is involved.- It rejects an ID token whose
noncedoesn't match. - If that fails and the person would land back on the sign-in page, the app signs them out and clears stored OIDC state first. The next sign-in sends
prompt=loginso Plain does not replay the session that just failed.
- Calling the API. Every GraphQL request sends
Authorization: Bearer <access_token>, exactly like an API key. Plain runs the call as the person who signed in. - Staying signed in. Shortly before the access token expires,
automaticSilentRenewswaps the refresh token for a new access token. If the tab slept past expiry,RequireAuthrefreshes it once on the next page load. - Signing out. Sign-out deletes the user and any in-flight PKCE state from this browser (
sessionStorageand any olderlocalStoragekeys). Plain's authorization server has no revocation or end-session endpoint, so the deleted access token would keep working until it expires. - No workspace. If
myWorkspaceis null, the sign-in is not usable. The app signs the person out, clears the saved session, and the next "Sign in" usesprompt=loginso they get the workspace step again instead of the same token.
If Plain rejects a token (it expired, or the OAuth app was turned off or deleted), the app drops the local session and asks you to sign in again. Turning an OAuth app off in Plain rejects every token it holds until you turn it back on.
All calls are POST https://core-api.uk.plain.com/graphql/v1. Each operation is written out as a GraphQL string in src/plain/operations.ts. To try one in Plain's API explorer, paste it together with the fragments it uses.
| Operation | Used for | Docs |
|---|---|---|
myUser, myWorkspace |
The signed-in person (for "Assigned to me") and their workspace (for "Open in Plain" links). myUser is only available to people, not machine users. |
Schema |
threads |
The queue. Filters by statuses, assignedToUser / isAssigned and priorities, sorted by STATUS_CHANGED_AT, 25 per page. |
Fetch threads, Pagination |
thread + timelineEntries |
One thread and its 50 most recent timeline entries, plus "Show earlier activity". Entries come back newest first, so after: endCursor fetches older ones; the app reverses them for display. |
Fetch threads |
replyToThread |
Replying on the thread's own channel (email, chat, Slack, Microsoft Teams or API). The reply is sent as the signed-in person. It appears in the timeline shortly after the mutation returns, so the app shows it straight away and refetches until it arrives. | Reply to threads |
markThreadAsDone, markThreadAsTodo |
Marking a thread as done, and moving it back to Todo. | Changing status |
A few details worth knowing:
- Permissions come from the person's role. OAuth tokens have no scopes of their own. Reading needs
thread:read(and the timeline needstimeline:read), replying needsthread:reply, and changing status needsthread:edit. When a permission is missing, Plain's error names it, and the app shows that error as-is. Sign in as someone with a read-only role to see it. - Queries and mutations report errors differently. A failed query comes back in GraphQL's
errorsarray with an extension code such asFORBIDDEN;plainRequest()throws it. A failed mutation still returns data, with anerrorobject containingmessage,type,codeandfields. See Error handling. - Timeline entries are a union. The app renders email, chat, Slack, Teams, Discord, note and internal discussion entries as messages. Status, assignment, priority and label changes are described from their own fields, because
llmText(Plain's plain-text rendering of an entry) is often null for events. Any other entry type falls back tollmText.textis aliased onChatEntrybecause it's nullable there and non-null on other entry types, and GraphQL won't select one field name with two types. - Prefer types to strings?
@team-plain/graphqlis Plain's typed TypeScript SDK. Pass it the access token asapiKey.
This is an example, so these choices are spelled out:
- No secrets in the app. As a public OAuth client, it uses PKCE instead of a client secret. Nothing in the bundle is sensitive except the tokens a person gets after signing in.
- Tokens live in
sessionStorage. They are scoped to the tab and cleared when it closes. Any JavaScript running on the page can read them, so a cross-site scripting bug would expose them. In production:- don't load third-party scripts
- serve a strict Content-Security-Policy whose
connect-srcallows onlyhttps://signin.auth.plain.comandhttps://core-api.uk.plain.com - keep dependencies up to date
- Want tokens out of the browser entirely? Put a small backend in front of the API (a "backend for frontend") and keep the tokens server-side.
- Rate limits. Plain enforces a per-workspace rate limit. The app shows a message on HTTP 429, but doesn't retry.
npm run build writes a static site to dist/, and any static host can serve it. Two things to set up:
- Redirect URL. Add
https://<your-domain>/callbackto the OAuth app's redirect URLs in Plain. - Single-page app fallback. The host must serve
index.htmlfor every path, so/callbackand/threads/...work when loaded directly:- On Vercel, add a
vercel.jsonwith{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }. - On Netlify, add a
public/_redirectsfile containing/* /index.html 200.
- On Vercel, add a
Set VITE_PLAIN_OAUTH_CLIENT_ID in your host's build environment.

