Appearance
Single Sign-On (SAML) ​
Overview ​
Workspace owners on a plan that includes SSO can connect a SAML identity provider (Microsoft Entra ID, Google Workspace, Okta or any other SAML provider) so members sign in with their company account. The frontend never handles SAML itself: the API exposes the service-provider values, syncs the IdP metadata, runs a connection test and records sign-in activity; collage-admin drives that through a four-step setup wizard, a summary page, an SSO tile on the login page and a 428 re-check in the fetch plugin.
Shipped in PR #110 (branch sunil/enterprise-sso-frontend, merged 2026-10-26), with follow-ups in PR #112 and PR #114.
Two flags on the workspace object from /user (accessibleWorkspaces[], type Workspace in app/types/layout.ts) control everything:
| Flag | Checked by | Effect when false |
|---|---|---|
is_owner | useHelpers().canManageSsoSettings(workspace) | can-access-sso-settings raises a 401 fatal error; the settings menu hides Single Sign-On |
sso_allowed | useHelpers().isSsoAllowed(workspace) | The SSO page shows an upgrade state (showUpgrade) and loads nothing; the settings menu hides Single Sign-On; the members page gets ssoAllowed = false |
Both helpers compare with === true, so a missing flag counts as false.
File Structure ​
| File | Role |
|---|---|
app/pages/[workspace_id]/workspace-settings/sso/index.vue | Summary page (ROUTE_WORKSPACE_SETTINGS_SSO, workspace_id-workspace-settings-sso) |
app/pages/[workspace_id]/workspace-settings/sso/setup.vue | Setup wizard and edit mode (ROUTE_WORKSPACE_SETTINGS_SSO_SETUP, workspace_id-workspace-settings-sso-setup) |
app/composables/core/pages/useSsoSettingsPage.ts | Page state shared by both pages: load, view resolution, sync polling, test, save, activity |
app/composables/core/pages/useSsoProviderDetails.ts | Active provider, brand-token substitution, SP values, copy-to-clipboard |
app/composables/core/pages/useSsoProviderChoice.ts | Remembers the chosen provider per workspace and user in localStorage |
app/composables/api/useSsoApi.ts | API calls and redirect URL builders |
app/stores/sso.ts | useSsoStore: connection cache keyed by workspace ID |
app/constants/sso.ts | SSO_SETUP_STEP_COUNT, SSO_SETUP_STEPS, SSO_BRAND_TOKEN, SSO_PROVIDERS |
app/types/sso.ts | All SSO types |
app/middleware/can-access-sso-settings.ts | Route guard |
app/components/org-settings/Sso*.vue | SsoPageHeader, SsoSetupStepper, SsoProviderLogo, SsoGuideDrawer, SsoGuideFlow, SsoActivityPanel (Components) |
app/assets/scss/components/_sso-settings.scss | Page styles |
app/pages/index.vue, app/pages/social-login.vue | SSO tile, work-email step, callback and blocked screen |
app/plugins/fetch.ts | 428 SSO re-check redirect |
app/utils/redirect.ts | isSafeRedirectUrl |
Both pages use general-settings-layout with middleware ['auth-check', 'check-workspace', 'can-access-sso-settings', 'check-if-suspended'], and both routes are in SIDEBAR_MINI_TARGET_ROUTES (useSidebarMiniState), so the sidebar collapses to mini on them.
API Contract ​
All calls go through $api and are wrapped in useApiRequestState().track.
| Function | Method and path | Body / query | Returns |
|---|---|---|---|
resolveSso(email) | POST auth/sso/resolve | { email } | SsoResolveResponse: url_slug, workspace_name, provider, redirect_url |
getConnection(workspaceId) | GET sso/connection?workspace_id= | SsoConnection | |
saveConnection(payload) | POST sso/connection | SsoConnectionUpdatePayload: workspace_id, optional metadata_url, allowed_domains, enabled, show_on_custom_domain | SsoConnection |
syncConnection(workspaceId, metadataUrl) | POST sso/sync | { workspace_id, metadata_url } | SsoSyncResponse: status, optional connection |
syncConnectionFile(workspaceId, file) | POST sso/sync | FormData: workspace_id, metadata_file | SsoSyncResponse |
testConnection(workspaceId) | POST sso/test | { workspace_id } | SsoTestResponse: ok, message, certificate_expires_at |
getActivity(params) | GET sso/activity?workspace_id=&page=&per_page= | page default 1, per_page default 20 | SsoActivityResponse: data, current_page, last_page, total |
Two helpers build the browser redirect to the IdP; neither calls the API:
buildRedirectUrl(urlSlug, returnHost, returnPath)returns<apiBaseUrl without trailing slashes>/auth/sso/<encodeURIComponent(urlSlug)>/redirect?return_host=<host>&return_path=<path>.withReturnParams(url, returnHost, returnPath)parsesurlagainstreturnHost, setsreturn_hostandreturn_path, and returns the string, ornullif the URL cannot be parsed.
Example (placeholder values):
text
https://api.example.test/api/auth/sso/acme/redirect?return_host=https%3A%2F%2Fapp.example.test&return_path=%2FSsoConnection ​
| Field | Type | Notes |
|---|---|---|
provider | string | |
enabled | boolean | The Status switch |
is_ready | boolean | True once metadata has synced; unlocks the Status switch and step 2 Continue |
metadata_url, metadata_source, metadata_file_name, metadata_uploaded_at | metadata_source is 'url', 'file' or null | |
idp_entity_id, idp_sso_url | string | null | From the synced metadata |
certificate_count, certificate_expires_at | certificate_expires_at is refreshed from a test response | |
allowed_domains | string[] | Email domains that may sign in |
email_attribute, stable_id_attribute | string | null | |
default_role | string | Role given to accounts created on first sign-in |
show_on_custom_domain | boolean | Also show the SSO tile on the workspace's custom domain |
last_synced_at, last_sync_status, last_sync_message | last_sync_status: never, queued, running, ok, failed | |
recheck_days, stale_days | number | UI falls back to 7 and 30 |
sp | { acs_url, entity_id, metadata_url } | Values the admin pastes into the IdP |
custom_domain | { url, domain_status, verified } | null | |
stats | { sso_members, auto_created, blocked_7d, stale_30d } | Summary stat boxes |
Sign-in Flow ​
check-branding now returns an sso object (SsoBranding: enabled, url_slug), which pages/index.vue stores as ssoBranding and useBrand() exposes as brand.sso (null on Collage defaults). The SSO tile shows when the host is the primary domain or ssoBranding.enabled === true. Google, Microsoft and passkey stay primary-domain only, so a white-label custom domain with SSO enabled shows only the SSO tile. With four tiles visible the row gets the tiles-4 class.
The IdP returns to /social-login. With ?access_token the page follows the same path as Google and Microsoft (apply session, fetch user, nextStep()), showing a lock icon and the passkey spinner colour #6473FF. Without a token it reads ?reason= and a base64 ?message= (decoded as UTF-8 via TextDecoder). For SSO with a decodable message it stays on a "We could not sign you in" screen with the message and a Back to login button, because a snackbar would be gone before the user read why they were blocked. Every failure is tracked as user login-failed with method and reason (default unauthorized).
The email regex is ^[^\s@]+@[^\s@]+\.[^\s@]+$.
428 re-check during a session ​
When any API call returns 428 (status or body code) on the client with data.redirect_url, fetch.ts sets window.location.href to that URL so the IdP can re-verify the member, and throws an error with isSsoRecheck: true. The URL must pass isSafeRedirectUrl(url, window.location.origin), which only accepts http: and https: after resolving relative URLs. Any other value falls through to the normal error rules. See Plugins.
Settings Pages ​
Access and menu entry ​
useAccountSettingsLeftMenu adds a Single Sign-On entry at index 7 of generalSettings, visible only when canManageSsoSettings(workspace) && isSsoAllowed(workspace). Unlike the other entries it does not depend on hasGeneralSettingsAccess. Its matchRoutes are both SSO routes, and while on either one the menu link keeps the current route name and query, so clicking the active entry from setup?edit=domains does not jump to the summary. On workspace switch, an SSO route stays on ROUTE_WORKSPACE_SETTINGS_SSO if the new workspace passes both checks; otherwise it takes the normal settings redirect.
Load and view resolution ​
useSsoSettingsPage(view) with view 'summary' or 'setup':
- Waits for the workspace from
accessibleWorkspaces. IfisSsoAllowedis false it stops and the page shows the upgrade state. - If
useSsoStore().connectionFor(workspaceId)has a cached connection it applies it immediately and refreshes in the background (refreshConnection). The refresh is applied only if the form is not dirty and no sync is running. Otherwise it callsgetConnectionwith a loading state. - On the first applied connection it decides where the user belongs. A connection is configured when
is_ready === trueandallowed_domainsis not empty.
| View | Configured | ?edit= | Result |
|---|---|---|---|
| summary | no | replace-navigate to setup | |
| summary | yes | stay | |
| setup | yes | domains or status | stay in edit mode on step 3 or 4 |
| setup | yes | none or invalid | replace-navigate to summary |
| setup | no | any | drop the query; start at step 3 if is_ready, else step 1 |
While a redirect is pending, contentLoading stays true so the wrong page never flashes. On the summary, a later connection whose configured state flips re-runs this table.
The tab is in the URL: ?tab=activity selects Sign-in activity (tab 1); Connection (tab 0) removes the key. Tab changes use replace navigation.
Setup wizard ​
SSO_SETUP_STEPS: 1 Identity provider, 2 Connect, 3 Who can sign in, 4 Test and go live (SSO_SETUP_STEP_COUNT = 4).
| Step | Leave rule (canLeaveStep) | Blocked message |
|---|---|---|
| 1 Identity provider | always | |
| 2 Connect | connection.is_ready === true | Continue unlocks after a successful sync. |
| 3 Who can sign in | at least one allowed domain | Add at least one domain to continue. |
| 4 Test and go live | a test has run this session (tested) | Run the connection test to enable Save. |
The stepper only lets users jump back to steps they have reached (maxVisitedStep) and is locked in edit mode. On step 4 the primary button reads Save and go live when Status is on, else Save; it saves only if the form is dirty, then goes to the summary. Step 4 also shows a summary sentence built from the allowed domains, provider short name, default_role and recheck_days.
Step 1 lists SSO_PROVIDERS: microsoft (Microsoft Entra ID, gives a metadata URL), google (Google Workspace, gives a file), okta (Okta, URL) and other (Other SAML provider, either). The choice is stored in localStorage under sso-provider-choice:<workspaceId>:<userId>; an unknown stored ID falls back to the first provider. Arrow-key focus wraps around the list (focusProviderAt). Provider texts contain {brand} (SSO_BRAND_TOKEN), replaced with Collage on unbranded hosts, the brand name on white-label hosts, or the app when a branded host has no name. Each provider has a step-by-step guide opened in SsoGuideDrawer, titled Connect <appName> and <provider name>.
Step 2 shows the SP values to paste into the IdP (for Microsoft: Identifier (Entity ID) from sp.entity_id and Reply URL (Assertion Consumer Service URL) from sp.acs_url), then accepts the IdP metadata.
Metadata rules:
- URL: required unless a file is chosen or a metadata source already exists; must parse as a URL with protocol
https:(Enter an https URL). - File: must end in
.xml(Choose the XML metadata file your identity provider gave you) and be at most 2 MB (That file is larger than 2 MB). Drag-and-drop and file input both go throughacceptMetadataFile. canSyncis true when not already syncing and either a file is chosen or a valid URL is entered. A file takes priority over the URL and is cleared after upload.
Metadata sync ​
A poll that throws stops polling with reason status_check. Polls never overlap (pollInFlight). A settled poll result is applied to the form only when the form is not dirty; otherwise it only updates the cache, so typed domains are not overwritten. The sync alert is hidden when its message equals last_sync_message already shown on the page, to avoid saying the same thing twice. Polling stops on unmount. Starting a sync resets tested, so a re-sync always needs a new test before Save.
The sync status line reads Synced <MMM D, h:mm a> for ok with a usable date, the API message (or Last sync failed) for failed, and Not synced yet, Sync queued or Sync running otherwise.
Allowed domains ​
addDomain normalizes the input before validating, so admins can paste an email, URL or host:
- trim and lowercase
- strip a scheme (
https://) - cut at the first
/,?or# - keep what follows the last
@ - drop a
:port - strip trailing dots
Examples: https://Acme.com/login becomes acme.com; [email protected] becomes acme.com.
The result must match ^(?!-)[a-z0-9-]+(\.[a-z0-9-]+)+$ (case-insensitive). Errors: Enter a domain, Enter a valid domain, for example acme.com, This domain is already on the list.
Test and save ​
runTest calls POST sso/test and shows message as a success or error alert depending on ok; a returned certificate_expires_at updates the cached connection. tested becomes true only when the request succeeds (even if ok is false).
The Status switch (enabled) is disabled until is_ready, with the tooltip Sync the identity provider metadata first. The switch unlocks once the connection is ready.
canSave requires: not saving, form dirty (JSON compare of enabled, show_on_custom_domain, trimmed metadata_url, allowed_domains against the last applied connection), no metadata URL error, and at least one domain. metadata_url is sent only when non-empty. After a save the activity list is marked stale (and reloaded if its tab is open) and the snackbar shows the API message or Single sign-on settings saved..
Summary page and edit mode ​
The summary shows four stat boxes (Members using SSO, Created by first sign-in, Blocked, last <recheck_days> days, No sign-in in <stale_days> days), the provider, allowed domains (first domain plus +N more), Status, and the registered values (SP values, <appName> metadata if not already listed, then either IdP metadata file or IdP metadata URL). The header pill is Live or Connected · off.
Edit buttons open setup?edit=domains (step 3) or setup?edit=status (step 4). In edit mode, Cancel returns to the summary, Save saves if dirty and returns.
Sign-in activity ​
Loaded the first time the activity tab opens, 20 rows per page (ACTIVITY_PER_PAGE), appended on scroll via loadMoreActivity while current_page < last_page. Concurrent loads are dropped. Outcomes: ok/signed_in Signed in, blocked Blocked, system System.
Members List ​
MemberUser gained sign_in_method, sso_provider and sso_last_verified_at. The members page shows a Sign-in method column on every tab: Password unless sign_in_method === 'sso', then Microsoft SSO, Google SSO, Okta SSO by sso_provider (case-insensitive), else SSO. Last Login (active users only) is parsed as UTC and shown as MMM D, YYYY h:mm a, with a tooltip. useMembersPage exposes ssoAllowed for the current workspace. The list request now builds its query with URLSearchParams, so search text with & or # no longer breaks the URL (16a2e85f).
Analytics ​
| Constant | Event | Properties |
|---|---|---|
WORKSPACE_SSO_METADATA_SYNCED | workspace sso-metadata-synced | source: url or file |
WORKSPACE_SSO_SYNC_FAILED | workspace sso-sync-failed | source, reason: failed, timeout, request, status_check; status for request errors |
WORKSPACE_SSO_TESTED | workspace sso-tested | ok; status on request error |
WORKSPACE_SSO_SETTINGS_UPDATED | workspace sso-settings-updated | allowed_domains (count), metadata_source, show_on_custom_domain |
WORKSPACE_SSO_TOGGLED | workspace sso-toggled | enabled; sent only when Status changed in that save |
Edge Cases ​
- Plan removed later: the route guard only checks
is_owner; an owner withoutsso_allowedreaches the page and sees the upgrade state, not an error. - Cache and redirects: a cached connection is applied before the network call, so a configured workspace goes straight to the summary without a spinner.
- Unsafe redirect URLs: both the login resolve flow and the 428 handler refuse anything that is not
http/https. - Branded hosts without a name: guide text says
the appinstead of an empty string. - Copy feedback: copying shows
<label> copied to clipboardand swaps to a check icon for 1.4 s (COPIED_RESET_MS); a clipboard failure showsFailed to copy.