Features
Authentication
Sign-in lives in the shell. Connect the identity-service or Supabase, protect routes, and decide who can join - public, a domain allow list, or invitation only.
Sign-in lives in the shell
Your microfrontend does not ship a login page. The shell owns /login and /login/callback, stores the session, refreshes tokens, and shares the signed-in user with iframes through the SDK.
The default backend is the identity-service — OAuth with GitHub, Google, or Microsoft, JWT sessions, and JWKS so other services can verify tokens. If Supabase Auth is already in your stack, point backend.type at it instead.
1. Connect a backend
Set backend in shellui.config.ts and enable the login methods you want on the page.
2. Protect routes
Mark navigation with requiresAuth. Signed-out visitors go to login, then back to the page they asked for.
3. Login from the iframe
OAuth must run in the top window. From an embedded app, call shellui.login() so the shell can start the redirect.
import type { ShellUIConfig } from "@shellui/core";
const config: ShellUIConfig = {
backend: {
type: "shellui",
url: "http://localhost:8000",
companyId: 1,
login: {
methods: ["oauth"],
oauthProviders: ["github", "google"],
},
},
navigation: [
{
label: "Billing",
path: "billing",
url: "https://app.example.com/billing",
requiresAuth: true,
},
],
};
export default config;Who can join
After a successful OAuth login, the company decides whether that person actually gets tokens. Access is per company: the same account can be enabled in one tenant and waiting in another. Configure the mode in admin, or patch it on the identity API.
Public
Anyone who authenticates joins with access enabled and receives tokens. The default when you are still opening the doors.
Domain allow list
Emails on allowed_email_domains join enabled. Other domains create a disabled membership and owners get notified.
Invitation only
New users land as disabled members. Tokens stay blocked until an owner or staff enables them for that company.
await fetch(`${backendUrl}/api/v1/companies/${companyId}/`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
access_mode: "public",
}),
});await fetch(`${backendUrl}/api/v1/companies/${companyId}/`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
access_mode: "domain",
allowed_email_domains: ["acme.com"],
}),
});await fetch(`${backendUrl}/api/v1/companies/${companyId}/`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
access_mode: "invite",
}),
});Groups, logs, and tokens
The identity-service keeps a company directory next to the session. Staff and company owners manage groups, read login events, and mint access tokens for people and for services that call your APIs without a browser.
Group names travel on the JWT as user.groups. Login events record success or failure, provider, and privacy-oriented client fields. Personal access tokens are named JWTs you can revoke; mark them read-only when a service only needs to fetch data.
import { useAuth } from "@shellui/core";
const { user } = useAuth();
const groups = user?.groups ?? [];
await fetch(`${backendUrl}/api/v1/groups`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ name: "billing" }),
});const events = await fetch(
`${backendUrl}/api/v1/login-events?outcome=success`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
).then((response) => response.json());const { access_token } = await fetch(
`${backendUrl}/api/v1/personal-access-tokens`,
{
method: "POST",
headers: {
Authorization: `Bearer ${sessionToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "billing-service",
read_only: true,
}),
},
).then((response) => response.json());Next, see how Administration manages users, company access, and operational data.