Skip to main content
Version: v0.2.74

Site Users

A site user is a seat-free, read-only account for someone who only ever opens a published app (a webapp) — never Honeyframe's build surface. They're the right identity for the actual audience of a published app: report consumers, branch staff, partner viewers — people you want to give personal, revocable, group-scoped access to without spending a licensed seat on each of them.

A site user is always read-only, unconditionally. This isn't enforced by which groups they happen to be in — it's checked and denied before any group lookup runs at all, regardless of what a group would otherwise grant. The role field on a site-user account (viewer or editor) is bookkeeping only; it is never consulted by the permission check, so setting role: "editor" does not grant write access. All real access comes from group membership, exactly as described in Groups & Permissions.

Site users belong to exactly one deployment at a time — identified by a subscription_id, the id of the licensed product instance a published app runs under. It's fixed per deployment, not something an end user ever types; your Honeyframe admin or account team gives you the value.

Inviting​

Only an org admin can create a site user:

POST/api/site-auth/invite🔒 auth

Body: {subscription_id: number, email: string, full_name?: string, role?: "viewer" | "editor"} (role defaults to viewer). Returns {site_user_id, email, temp_password}.

The temp password is returned once, in the response — the same "shown-once" convention as a Personal Access Token. Relay it to the person out of band; there is no invite email today, and there is currently no self-service or admin-triggered password reset for a site user either. If a credential needs to be revoked, deactivate the account (below) rather than trying to rotate its password.

Activating and managing​

A site user's account is active immediately on invite — there's no separate activation step. From there, an admin manages it directly:

PATCH/api/site-auth/users/:site_user_id🔒 auth

Body: any of {is_active?, role?, full_name?, site_keys?}. Set is_active: false to deactivate — this takes effect immediately, on the person's very next request, not at their token's next natural expiry.

DELETE/api/site-auth/users/:site_user_id🔒 auth

Removes the account entirely.

GET/api/site-auth/users🔒 auth

Query param subscription_id. Lists a deployment's site users (admin view).

Attaching to groups​

POST/api/site-auth/users/:site_user_id/groups/:group_id🔒 auth

Adds the site user to an authorization group — the same groups platform users belong to.

DELETE/api/site-auth/users/:site_user_id/groups/:group_id🔒 auth

Removes the site user from the group.

A site user's access is exactly the union of the dashboard.view / project.view grants held by every group they're in — see Groups & Permissions for granting those. Unlike a platform viewer, a site user gets no implicit org-wide grant from their role: a site user in zero groups can reach nothing.

Site scoping (site_keys)​

A site user can optionally be restricted to a list of site codes — whatever your deployment models as a "site" (a branch, a hospital, a store, a region). site_keys is a nullable list on the account:

  • null — unrestricted. The person is not scoped to any particular site.
  • A populated array (e.g. ["CWI", "JTN"]) — restricted to only those site codes.

Login flow (for application developers)​

POST/api/site-auth/login🔒 auth

Body: {subscription_id: number, email: string, password: string}. Returns {access_token: string, site_user_id: number, subscription_id: number, role: string} — four fields, no more.

curl -sS -X POST "https://app.your-domain.com/api/site-auth/login" \
-H "Content-Type: application/json" \
-d '{"subscription_id":42,"email":"viewer@example.com","password":"..."}'
type SiteLoginResponse = {
access_token: string;
site_user_id: number;
subscription_id: number;
role: string;
};

async function siteLogin(
subscriptionId: number, email: string, password: string,
): Promise<SiteLoginResponse> {
const res = await fetch(`${BASE}/api/site-auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription_id: subscriptionId, email, password }),
});
if (res.status === 401) throw new Error('Invalid credentials');
if (res.status === 403) throw new Error('Account deactivated');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}

Use the returned access_token exactly like any other bearer token — Authorization: Bearer <token> — on every subsequent call.

Where to hold the token: prefer memory only (a JS variable / framework context) — nothing survives a refresh, so a stolen-token window is bounded by the tab's lifetime. sessionStorage is a reasonable, documented exception if your app needs the session to survive a page refresh; both are readable by any script on the page (equal XSS exposure), but sessionStorage at least doesn't leak across tabs or browser restarts the way localStorage does. Avoid localStorage — it has no expiry of its own and is the highest-blast-radius option if the page is ever compromised.

Token lifetime and refresh: the token's expiry follows your deployment's configured session lifetime — ask your operator or account team for the exact value. There is no refresh endpoint today. On a 401, send the person back to the login form; don't build a silent-refresh path against an endpoint that doesn't exist.

What a logged-in site user can see — me/permissions​

GET/api/site-auth/me/permissions🔒 auth

Site-user bearer token only — a platform token here is rejected. Returns the resolved menu: everything this identity is authorized to open.

This is the endpoint your app should render its navigation from — don't try to derive access from /me (which only echoes account fields) or by trial-and-error against the data endpoints.

{
"site_user_id": 17,
"email": "viewer@example.com",
"full_name": "Person Name",
"role": "viewer",
"subscription_id": 42,
"site_keys": null,
"groups": [
{ "id": 5, "name": "Report Viewers" }
],
"grants": [
{ "resource_type": "dashboard", "id": 12, "name": "Branch Performance", "access": "view" },
{ "resource_type": "webapp", "key": "ops-cockpit", "name": "Ops Cockpit", "access": "view" }
]
}

Field notes:

  • site_keys mirrors the account attribute above — null for unrestricted, an array for a site allowlist. See the caveat above before building anything against it.
  • groups is informational — the groups this identity belongs to, by id and name.
  • grants is the concrete, resolved list of what this identity can open. Two resource_types: "dashboard" (keyed by numeric id, matching the id used on the card-execute endpoints) and "webapp" (keyed by its published key).

There is no 403 on me/permissions itself: a valid identity with zero grants gets 200 back with grants: []. Render that as an empty state, not an error — it's a normal condition for a freshly invited person who hasn't been added to a group yet.

Handling 401 vs 403​

These two codes mean different things and should drive different UI — don't collapse them into one generic error state.

StatusMeaningWhat to do
401The token itself doesn't resolve: missing, malformed, expired — or a syntactically valid token whose account has since been deactivated or deleted. Account state is re-read on every request, so a revocation takes effect immediately, mid-session, not at the token's natural expiry.Discard the token, redirect to login. There's no recovering in place.
403The token is valid and the account is active, but this identity lacks the specific grant the call needs.Show "no access to this report" for that resource only. Don't log the person out — the rest of the app, wherever they are granted, keeps working.

One wrinkle: at login time, a deactivated account gets a 403 ("Account deactivated"). Later, on an already-issued token, the same deactivation surfaces as 401 instead — at that point there's no meaningful difference between "never existed," "deleted," and "deactivated," and none of those are a per-resource permission question. Treat both as "this session is no longer valid" in your UI, regardless of which code surfaces it.

The 403 body carries a fixed, parseable shape:

{
"detail": {
"error": "permission_denied",
"permission": "dashboard.view",
"target_id": "12",
"message": "Your group memberships do not grant 'dashboard.view' on 12."
}
}
async function callDataApi(req: () => Promise<Response>) {
const res = await req();
if (res.status === 401) {
clearToken();
redirectToLogin();
throw new Error('session expired');
}
if (res.status === 403) {
// scoped to this one report/card/block — do not touch the session
return { deniedThisReport: true };
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}

What doesn't change​

Every data endpoint a published app actually calls — the config fetch, card execute, execute-batch, block execute, and filter-options preview — is identical in URL, method, request shape, and response shape whether the caller holds a Personal Access Token or a site-user token. The server resolves whichever credential kind is presented through the same dependency, and routes to whichever resolver matches. See the Webapp Data API for that full contract; this page covers only how a per-user identity is minted and what it's authorized to reach.