Frontend Integration
Integrations — Frontend Integration
This is the contract the crm-monorepo frontend implements so that external apps (starting with wieeo) can offer a "Connect with Corteksa" button. The backend (Corteksa) is a full OAuth 2.0 provider; the frontend builds the consent UI and the connected-apps management UI on top of it.
Scope of this document. Everything here is about the first-party Corteksa web app (the logged-in workspace user). It does NOT describe the external partner app (wieeo) — that side (PKCE, token exchange, calling the API with the token) is summarized in §7 only so you understand the whole picture. The browser never touches client secrets or access tokens.
1. What the frontend builds
Two screens, both in the user app (apps/user, logged-in workspace user,
Bearer-authenticated — the same auth every other CRM page uses):
| # | Screen | Purpose | Endpoints |
|---|---|---|---|
| A | Consent screen | The page an external app sends the user to. Shows "wieeo wants access to: Read contacts, Create contacts" with Approve / Deny. | GET + POST /api/v1/oauth/authorize |
| B | Connected apps (settings) | The user reviews and revokes apps they've authorized. | GET + DELETE /api/v1/oauth/authorizations |
Optionally, a super-admin screen in apps/admin to register/manage partner
apps (§6) — this is the only
place apps get created.
Base URL & auth
- All endpoints are under
/api/v1on the workspace's own subdomain — the exact host the frontend already calls. Use the existingapiClientso the tenant subdomain and theAuthorization: Bearer <user JWT>header are attached automatically. No special base URL. - The two consent endpoints and both connected-apps endpoints require the
logged-in user (same guard as the rest of the app). A
401means "log in".
Response envelope
Every endpoint here except /oauth/token and /oauth/revoke returns the
standard app envelope — read data:
{ "status": 200, "message": "Success", "data": { /* ... */ } }(apiClient already unwraps this the way it does everywhere else.)
2. Screen A — the consent screen
2.1 How the user arrives
The external app opens (in the user's browser) a URL on the workspace subdomain that points at your consent route, carrying standard OAuth query params:
https://{workspace}.corteksa.cloud/{locale}/oauth/authorize
?client_id=crtk_app_abc123
&redirect_uri=https://wieeo.app/callback
&scope=read.contacts%20create.contacts
&state=xyz789
&code_challenge=E9Melhoa2Ow... (present for public/PKCE apps)
&code_challenge_method=S256
&response_type=codePick the frontend route (e.g. /[locale]/oauth/authorize, a blank-layout
authenticated page — no dashboard chrome, but still behind login). The exact
path is your choice; whatever you choose is what a super-admin registers as the
partner's redirect target's origin — see the note in §6.
⚠️ Preserve every query param byte-for-byte. You will read them and pass them straight back to the backend on both the GET and the POST. Do not drop, reorder, or re-encode
code_challenge/state— the backend re-derives security decisions from them and the partner app checksstateitself.
2.2 Not logged in → login, then come back
The page requires auth. If the user isn't authenticated, redirect to login with a return URL back to this exact page including all query params, then return here after login. (Reuse the app's existing "redirect to login with return URL" mechanism — this is the same pattern as any other protected deep link.)
2.3 Step 1 — fetch consent details (GET)
Call with the same query params the browser received:
GET /api/v1/oauth/authorize?client_id=…&redirect_uri=…&scope=…&state=…&code_challenge=…&code_challenge_method=S256
Authorization: Bearer <user JWT> ← via apiClient200 response data (ConsentDetails):
interface ConsentDetails {
app: {
name: string; // "wieeo" — show prominently
logo_url: string | null; // app icon, may be null → show a placeholder
client_id: string;
};
scopes: Array<{
route_name: string; // "read.contacts" — machine id
description: string; // "Read contacts" — HUMAN label, show this
}>;
redirect_uri: string; // echoed; show its host so the user sees where data goes
state: string | null; // echoed; pass back untouched on POST
already_authorized: boolean; // true → user previously granted ≥ these scopes
}Render:
- App name + logo.
- The host of
redirect_uri(e.g. "You'll be returned to wieeo.app"). - One row per
scopes[i].description(a checklist of what's being granted). - Approve and Deny buttons.
- If
already_authorized === true, you may show "You've already connected this app" and still require an explicit Approve click (recommended), or auto-submit approve. Never silently approve new scopes.
Error responses on the GET — do NOT redirect anywhere:
| Status | Meaning | UX |
|---|---|---|
400 | Bad request: unknown/inactive client_id, redirect_uri doesn't exactly match a registered URI, requested scope not allowed for the app, PKCE missing for a public client, response_type ≠ code. | Show a plain error screen ("This app can't be connected"). Never redirect the browser to redirect_uri — the backend deliberately withholds a redirect_to here (open-redirect defense). |
401 | Not logged in / token expired. | Send to login with return URL (§2.2). |
2.4 Step 2 — submit the decision (POST)
On Approve or Deny, POST the decision. Send back the same params plus
decision:
POST /api/v1/oauth/authorize
Authorization: Bearer <user JWT>
Content-Type: application/jsoninterface AuthorizeDecisionBody {
client_id: string;
redirect_uri: string;
scope?: string; // space-separated, same as received
state?: string; // pass through untouched
code_challenge?: string; // pass through untouched
code_challenge_method?: 'S256';
decision: 'approve' | 'deny';
}200 response data:
interface AuthorizeDecisionResult {
redirect_to: string; // FULL url the browser must go to now
}- Approve →
redirect_to={redirect_uri}?code=…&state=… - Deny →
redirect_to={redirect_uri}?error=access_denied&state=…
In both cases, perform a full-page browser redirect to redirect_to:
window.location.assign(data.redirect_to); // NOT next/navigation router.pushUse a hard navigation (not a client-side route change) — you're leaving the Corteksa SPA and going to the partner's domain.
Error on the POST:
400— e.g. approve but none of the requested scopes are grantable by this user's permissions ("None of the requested scopes can be granted by your account"). Show the error; do not redirect.401— login (§2.2).
2.5 Consent flow at a glance
Browser lands on /oauth/authorize?client_id&redirect_uri&scope&state&code_challenge
│ (not logged in?) → login → back here (params preserved)
├─ GET /api/v1/oauth/authorize ──→ ConsentDetails → render app + scopes
│ └─ 400 → error screen, DO NOT redirect
└─ user clicks Approve / Deny
POST /api/v1/oauth/authorize {…, decision} ──→ { redirect_to }
└─ window.location.assign(redirect_to) → back to wieeo (with code or error)3. Screen B — connected apps (settings)
A user reviews and disconnects the apps they've authorized in this workspace. Put it under settings (e.g. Settings → Connected apps).
3.1 List
GET /api/v1/oauth/authorizations Authorization: Bearer <user JWT>200 data — ConnectedApp[]:
interface ConnectedApp {
slug: string; // use for the revoke call
app_client_id: string;
app_name: string; // "wieeo"
app_logo_url: string | null;
scopes: string[]; // route_names granted; humanize for display
authorized_at: string; // ISO timestamp
}Render one card/row per app: logo, name, granted scopes, "Connected on {authorized_at}", and a Disconnect button.
3.2 Revoke (disconnect)
DELETE /api/v1/oauth/authorizations/{slug} Authorization: Bearer <user JWT>200 → { "status": 200, "message": "Authorization revoked", "data": null }
Revoking cascades: it kills the grant and the app's live access/refresh tokens for this user immediately — the app loses access at once, not at token expiry. Confirm before calling (destructive), then refresh the list.
404→ the grant doesn't exist (or isn't the caller's). Refresh the list.
4. Copy-paste TypeScript types
// ---- shared envelope (except /oauth/token, /oauth/revoke) ----
interface ApiEnvelope<T> { status: number; message: string; data: T; }
// ---- Screen A: consent ----
export interface ConsentScope { route_name: string; description: string; }
export interface ConsentDetails {
app: { name: string; logo_url: string | null; client_id: string };
scopes: ConsentScope[];
redirect_uri: string;
state: string | null;
already_authorized: boolean;
}
export interface AuthorizeDecisionBody {
client_id: string;
redirect_uri: string;
scope?: string;
state?: string;
code_challenge?: string;
code_challenge_method?: 'S256';
decision: 'approve' | 'deny';
}
export interface AuthorizeDecisionResult { redirect_to: string; }
// ---- Screen B: connected apps ----
export interface ConnectedApp {
slug: string;
app_client_id: string;
app_name: string;
app_logo_url: string | null;
scopes: string[];
authorized_at: string;
}5. Endpoint reference (frontend-facing)
| Method | Path | Auth | Body / Query | Returns (data) |
|---|---|---|---|---|
GET | /api/v1/oauth/authorize | user Bearer | query: client_id, redirect_uri, scope?, state?, code_challenge?, code_challenge_method?, response_type? | ConsentDetails |
POST | /api/v1/oauth/authorize | user Bearer | AuthorizeDecisionBody | { redirect_to } |
GET | /api/v1/oauth/authorizations | user Bearer | — | ConnectedApp[] |
DELETE | /api/v1/oauth/authorizations/:slug | user Bearer | — | null (message only) |
6. (Optional) Super-admin app registry
Only a super-admin registers partner apps (curated model). If you build a UI
for it, these are the endpoints (all Bearer + super-admin):
| Method | Path | Body | Notes |
|---|---|---|---|
POST | /api/v1/oauth/apps | CreateOAuthApp (below) | Returns client_id, and client_secret once for confidential apps — show a "copy now, won't be shown again" modal. |
GET | /api/v1/oauth/apps | — | List all apps (OAuthApp[]). |
PATCH | /api/v1/oauth/apps/:slug | partial { redirect_uris?, allowed_scopes?, name?, description?, logo_url?, is_active? } | Edit. |
DELETE | /api/v1/oauth/apps/:slug | — | Remove the app + revoke all its tokens. |
interface CreateOAuthApp {
name: string; // ≤120 chars
description?: string; // ≤500
logo_url?: string; // valid URL, ≤500
redirect_uris: string[]; // ≥1; EXACT https URIs (localhost http ok for dev)
allowed_scopes: string[]; // ≥1 route_names, e.g. ["read.contacts","create.contacts"]
client_type?: 'confidential' | 'public'; // default confidential; public ⇒ PKCE, no secret
}
// POST response data: { client_id: string; client_secret?: string; app: OAuthApp }
interface OAuthApp {
slug: string; client_id: string; name: string; description: string | null;
logo_url: string | null; redirect_uris: string[]; allowed_scopes: string[];
client_type: 'confidential' | 'public'; is_active: boolean; created_at: string;
}
redirect_urisare matched exactly at authorize time. The origin the super-admin registers is the partner's real callback (e.g.https://wieeo.app/callback) — it is not the Corteksa consent route. The consent route is just where the browser renders the screen; thecodeis delivered to the partner's registeredredirect_uri.Scope catalog:
allowed_scopesand the consentscopeare route-name strings — the same permission ids the app already uses (read.contacts,create.deals,update.contacts, …). Reuse whatever permission-name catalog the permissions UI already has to pick scopes.
7. What the frontend does NOT build (the partner side)
For context only — this is wieeo's code, not crm-monorepo, and never runs in the Corteksa browser:
- wieeo generates a PKCE
code_verifier+code_challengeand sends the user to the Corteksa consent route (§2.1). - After the user approves, the browser lands on wieeo's
redirect_uriwith?code=…&state=…. - wieeo's backend exchanges the code for tokens — server-to-server, with its
client_secret(confidential) orcode_verifier(public/PKCE):POST /api/v1/oauth/token (raw RFC 6749 JSON — NO app envelope) { grant_type: "authorization_code", code, redirect_uri, client_id, client_secret | code_verifier } → { access_token: "crtk_oauth_…", token_type: "Bearer", expires_in: 3600, refresh_token: "crtk_refresh_…", scope } - wieeo calls the CRM with the token:
Authorization: Bearer crtk_oauth_…, refreshing viagrant_type: "refresh_token", and can revoke viaPOST /api/v1/oauth/token…/oauth/revoke.
The Corteksa browser SPA must never call /oauth/token, never handle a
client_secret, and never see a crtk_oauth_ / crtk_refresh_ token. Its
only job is the consent screen (which yields a short-lived code delivered to
the partner) and the connected-apps management screen.
8. Security checklist for the frontend
- ✅ Pass
code_challenge,code_challenge_method, andstatethrough untouched on both GET and POST. - ✅ Redirect only to the backend-returned
data.redirect_to. Never build a redirect from the rawredirect_uriquery param yourself. - ✅ On any
400from/oauth/authorize, show an error screen — do not redirect the browser anywhere (open-redirect defense). - ✅ Use a hard navigation (
window.location.assign) forredirect_to— you are leaving to the partner's domain. - ✅ The consent + connected-apps calls go through the normal
apiClient(user Bearer, workspace subdomain). Nothing here needs a new base URL or a secret. - ❌ Never call
/oauth/tokenor touch a client secret from the browser.
See also
- ARCHITECTURE.md — the backend design (opaque tokens, RLS isolation, PKCE/secret verification, refresh rotation, cascade revoke). </content> </invoke>