Appearance
Authentication Methods ​
Overview ​
Collage Admin supports five sign-in methods, all producing the same JWT session on success:
- Password login — email + password, handled on
pages/index.vue. Calls the Laravel/loginendpoint viauseAuth.login(). On success the JWT is written to theauth_tokencookie (primary source of truth), mirrored into a reactiveuseState('auth-token-mem'), and also persisted to localStorage as a legacy fallback for sessions whose cookie never persisted. - Google OAuth — redirects to Google consent screen; the backend callback redirects to
pages/social-login.vuewith?access_token=. Available on the primary app domain only (hidden on white-label deployments). - Microsoft OAuth — identical flow to Google. Provider is tagged via
sessionStorage(login_provider = 'microsoft') because Microsoft's callback carries noproviderparam. - Passkey (WebAuthn) — browser-native biometric / security-key ceremony via
@simplewebauthn/browser. Available only on the primary domain and whenbrowserSupportsWebAuthn()returns true. The bearer token is passed to/social-loginthroughsessionStorageto keep it out of the URL. - SSO (SAML) — a workspace's identity provider, set up by the owner on the Single Sign-On page. The SSO tile shows on the primary domain, and on a white-label host when
check-brandingreturnssso.enabled: true. The browser goes to the API'sauth/sso/<url_slug>/redirect, the IdP signs the user in, and the API redirects to/social-loginlike the OAuth providers (login_provider = 'sso').
Token state uses the auth_token cookie as the single source of truth — it is readable during SSR (request headers) and on the client, ensuring server and client seed identical state on first render. A reactive useState('auth-token-mem') reflects the cookie value in memory and is shared across all composable callers within a navigation. localStorage is a legacy fallback only, used for sessions whose cookie never persisted; it is reconciled after mount by app/plugins/auth-sync.client.ts.
Architecture ​
useAuth is the central composable. Every page and middleware calls it; a 30-second in-memory cache (useState('auth-user-fetch-cache')) and a per-NuxtApp in-flight WeakMap prevent duplicate /user fetches across concurrent middleware guards. Cookie read/write is delegated to useAuthCookies, which manages token, strategy, and workspace cookies in one place.
Passkey registration and management are separated into usePasskeyApi, which only covers the profile-settings side. The login ceremony lives entirely in pages/index.vue using @simplewebauthn/browser's startAuthentication().
Social and passkey logins both converge on pages/social-login.vue, which reads the token (from ?access_token or sessionStorage), calls fetchUser(), resolves the workspace, and calls nextStep() to navigate.
File Structure ​
Composable Files ​
app/composables/auth/useAuth.ts— primary auth composable- In-memory token state via
useState('auth-token-mem') - Cookie token via
useCookie('auth_token') - Per-NuxtApp in-flight fetch deduplication via
WeakMap - 30-second user-fetch cache via
useState('auth-user-fetch-cache') - Workspace resolution from
accessibleWorkspaces
- In-memory token state via
app/composables/auth/useAuthCookies.ts— centralised cookie layerreadToken,readStoredToken(cookie → localStorage fallback)readStrategy,readWorkspacewriteSession— writes token + strategy with correct max-age per strategy typewriteWorkspace— merges into stored value to preserve themodulefieldclearSessionCookies— called on logout and 401
app/composables/api/usePasskeyApi.ts— WebAuthn profile-settings API- Register and list passkeys for authenticated users
- Delete passkeys by ID
Utility Files ​
app/utils/passkey.ts— shared cancellation detection utilityisPasskeyCancellation(err)— distinguishes user dismissal (NotAllowedError/AbortError) from real failures- Used by both
pages/index.vueanduseProfilePage
Page Files ​
app/pages/index.vue— main login page- Password form with email + password validation
- Google, Microsoft, Passkey social tiles (primary domain only)
- Domain verification via
check-brandingAPI ononMounted - Support-platform variant (6-digit PIN instead of password)
- Referring URL cookie consumed on successful login to restore redirect
app/pages/social-login.vue— OAuth / passkey callback landing page- Reads
?access_token(Google/Microsoft) orsessionStorage.passkey_access_token(passkey) - Calls
fetchUser(), resolves workspace, callsnextStep() - Shows "We are logging you in…" skeleton during processing
- Reads
app/pages/forgot-password.vue— forgot password page- Email field; submits via
useDamInstanceSettingsApi.forgotPassword() - Redirects back to login on success
- Email field; submits via
app/pages/reset-password.vue— password reset confirmation page- Accepts the reset token from the URL and sets a new password
app/pages/password-setup.vue— initial password setup for invited usersapp/pages/profile.vue— authenticated user profile page; two tabs (Profile Settings / Notification Settings)app/pages/generate-password.vue— invitation acceptance page; validates aninvitation_tokenfrom the query string and lets a new user set their initial passwordapp/pages/[workspace_id]/feedback/index.vue— authenticated user feedback submission page; pre-fills name, email, and workspace from session
Page Details ​
Login (pages/index.vue) ​
| Field | Value |
|---|---|
| Route | / |
| Layout | login-layout |
| Middleware | guest-check |
| Primary composables | useAuth(), useDamInstanceSettingsApi(), useBrowserOsApi() |
On mount, starts the CSRF fetch (useCsrf().fetchCsrfToken()) without awaiting it, then calls the check-branding API to verify the current domain is whitelisted; both run in parallel (await Promise.all([csrfRequest, verifyDomain()]), since 38752451) and a CSRF failure is swallowed. verifyDomain() throws a fatal 404 if the domain is unrecognised (blocks non-whitelisted white-label deployments). Social login buttons (Google, Microsoft, Passkey) are shown only when window.location.origin matches the configured baseUrl; they are hidden on white-label domains. The SSO tile follows the same rule but is also shown when check-branding returns sso.enabled: true. Clicking it goes straight to the IdP when sso.url_slug is known, otherwise opens a work-email step that resolves the workspace with POST auth/sso/resolve (flow in Single Sign-On). The live forms render only once useBrand().brandingReady is true. Two form modes: standard email + password and a support-platform variant that uses a 6-digit PIN instead of a password. The passkey flow stores the token in sessionStorage and hands off to /social-login. On successful login, reads the referring_url_data cookie to restore any pre-expiry redirect.
Social Login (pages/social-login.vue) ​
| Field | Value |
|---|---|
| Route | /social-login |
| Layout | login-layout |
| Middleware | guest-check |
| Primary composables | useAuth(), useAuthCookies(), useHelpers() |
Resolves the access token from ?access_token (Google/Microsoft OAuth redirect) or sessionStorage.passkey_access_token (passkey flow). Calls applySession(), then fetchUser(), sets the workspace cookie, and redirects via nextStep(). Displays a static "We are logging you in…" loading screen with no interactive form (lock icon for SSO). Tracks success and failure paths in Amplitude.
Without a token it reads ?reason= (Amplitude failure reason, default unauthorized) and a base64, UTF-8 ?message=. For the sso provider with a decodable message it shows "We could not sign you in", the message and a Back to login button instead of a snackbar, so the user can read why they were blocked. Other providers show the decoded message (or You are not an authorized person to login.) as a snackbar and return to /.
Forgot Password (pages/forgot-password.vue) ​
| Field | Value |
|---|---|
| Route | /forgot-password |
| Layout | login-layout |
| Middleware | guest-check |
| Primary composable | useDamInstanceSettingsApi() (forgotPassword) |
Single email field. On success, shows the API-provided confirmation message and redirects to /. Tracks USER_PASSWORD_RESET_REQUESTED and USER_PASSWORD_RESET_REQUEST_FAILED in Amplitude.
Reset Password (pages/reset-password.vue) ​
| Field | Value |
|---|---|
| Route | /reset-password?token=… |
| Layout | login-layout |
| Middleware | guest-check |
| Primary composable | useDamInstanceSettingsApi() (getPasswordDetails, resetPassword) |
On mount, calls getPasswordDetails with the ?token query param to resolve the user's email and the internal reset_token. If the token is expired or invalid, throws a Nuxt 410 error. Password must be at least 8 characters. Redirects to / on success.
Password Setup (pages/password-setup.vue) ​
| Field | Value |
|---|---|
| Route | /password-setup?token=… |
| Layout | login-layout |
| Middleware | guest-check |
| Primary composable | useDamInstanceSettingsApi() (generateCommonPassword) |
First-time password creation for invited users. Stricter validation than reset-password: requires uppercase, lowercase, a number, and a special character (minimum 8 characters). After submit, shows a 3-second "account setup in progress" interstitial before redirecting to /. Tracks USER_PASSWORD_SET in Amplitude.
Feedback ([workspace_id]/feedback/index.vue) ​
| Field | Value |
|---|---|
| Route | /:workspace_id/feedback |
| Layout | profile-layout |
| Middleware | auth-check, check-workspace, can-access, check-workspace-access, check-if-suspended |
| Primary composables | useAuth(), useCommonApis() (sendFeedback), useBrand() |
User name, email, and workspace name are pre-filled from the session (read-only). Only the comment textarea is editable. On success, replaces the form with a thank-you message (no redirect); the brand name from useBrand() is interpolated into the thank-you text. Tracks FEEDBACK_SUBMITTED with comment_length in Amplitude.
Profile (pages/profile.vue) ​
| Field | Value |
|---|---|
| Route | /profile |
| Layout | profile-layout |
| Middleware | auth-check |
| Key composables | useAuthCookies, useAuth, useHelpers (inline — no dedicated page composable) |
The page uses a ?tab=notification query param to open the Notification Settings tab directly from external links (e.g., from notification emails). The Profile Settings tab allows updating display name, email, and password, and managing registered passkeys. The Notification Settings tab controls per-module email notification preferences.
Generate Password (pages/generate-password.vue) ​
| Field | Value |
|---|---|
| Route | /generate-password |
| Layout | login-layout |
| Middleware | none |
| Key composable | useDamInstanceSettingsApi() |
Accepts an invitation_token from the URL query string. On load the token is validated server-side; if expired the page renders an "invitation link expired" state with a prompt to request a new one. On successful password submission the composable tracks USER_INVITATION_ACCEPTED in Amplitude and redirects the user into the app.
useAuth ​
File: app/composables/auth/useAuth.ts
Central composable for all authentication state and operations. SSR-safe: uses useState for shared reactive state rather than module-level singletons.
Returned Properties ​
typescript
{
user: Ref<User | null> // reactive authenticated user
isAuthenticated: ComputedRef<boolean> // true if token OR user is present
isExternalSession: Ref<boolean> // true for external upload sessions
status: Ref<AuthStatus> // 'idle' | 'loading' | 'authenticated' | 'unauthenticated' | 'unreachable'
isBackendUnreachable: ComputedRef<boolean> // status === 'unreachable'
retryAuth: () => Promise<User | null> // reset back-off and fetchUser(true)
login: (credentials, options?) => Promise<User>
logout: () => Promise<void>
fetchUser: (forceRefresh?: boolean) => Promise<User | null>
clearAuthCookies: () => void
resolveCurrentWorkspace: (userData: User) => Workspace | null
}Key Methods ​
typescript
// login — calls /login or /support-login, stores token, fetches user
login(credentials: LoginCredentials, options?: { isSupport?: Ref<boolean> | boolean }): Promise<User>
// fetchUser — returns early if no token exists anywhere (cookie or localStorage).
// Otherwise: de-duped, cached 30s, handles external users (/get-external-user endpoint)
fetchUser(forceRefresh = false): Promise<User | null>
// logout — POSTs /logout (or /external-logout) with keepalive + skipCancel, waits at most
// LOGOUT_WAIT_MS (300) on the client, then clears cookie, localStorage, and all useState
logout(): Promise<void>
// clearAuthCookies — immediate token wipe without an API call (used by 401 handler)
clearAuthCookies(): void
// resolveCurrentWorkspace — picks workspace from cookie → user.workspace_id → first accessible
resolveCurrentWorkspace(userData: User): Workspace | nullAuth status and retry back-off ​
status lives in useState('auth-status') and the retry counter in useState('auth-retry') ({ attempts, nextRetryAt }). They exist to stop the /user request storm when the API is down (65cdeb93): before, every middleware re-fetched the user and every failure redirected to login.
| Event | status | Retry state |
|---|---|---|
| no token anywhere | unauthenticated | unchanged |
| fetch starts | loading | |
| user returned | authenticated | reset; clearSessionTeardown() |
| empty user data | unauthenticated | |
request aborted (isAbortError) | authenticated if a user is loaded, else idle | unchanged |
| failure with no status, status 0 or >= 500 | unreachable | attempts + 1, next retry after back-off |
| any other failure (for example 401, 403) | unauthenticated | attempts + 1 |
logout / clearAuthCookies | unauthenticated | reset |
Back-off: min(1000 * 2^attempts, 30000) ms with ±20% jitter. While attempts > 0 and the retry time has not passed, fetchUser() (without forceRefresh) returns the current user without a request. For unreachable failures only the first failure shows a snackbar; 401s and origin_url errors never do. auth-reconnect.client.ts calls retryAuth() on the browser online event when the backend was unreachable.
Usage Example ​
vue
<script setup lang="ts">
const { login, isAuthenticated, user, logout } = useAuth()
async function handleLogin(email: string, password: string) {
const loggedInUser = await login({ email, password })
// user is now set; navigate to workspace
await navigateTo({
name: 'workspace_id-dam',
params: { workspace_id: loggedInUser.currentWorkspace?.id },
})
}
</script>usePasskeyApi ​
File: app/composables/api/usePasskeyApi.ts
Profile-settings side of the WebAuthn ceremony. All endpoints are authenticated — $api attaches the Bearer token automatically.
Methods ​
typescript
{
getPasskeys: () => Promise<Passkey[]>
getRegisterOptions: () => Promise<PublicKeyCredentialCreationOptionsJSON>
registerPasskey: (payload: RegistrationResponseJSON & { alias: string }) => Promise<ApiResponse>
deletePasskey: (id: number | string) => Promise<ApiResponse>
}Usage Example ​
vue
<script setup lang="ts">
import { startRegistration } from '@simplewebauthn/browser'
const { getRegisterOptions, registerPasskey, getPasskeys } = usePasskeyApi()
async function addPasskey() {
const options = await getRegisterOptions()
const credential = await startRegistration({ optionsJSON: options })
await registerPasskey({ ...credential, alias: 'My MacBook' })
const list = await getPasskeys()
}
</script>Passkey Login Flow ​
The login ceremony is embedded directly in pages/index.vue. It uses @simplewebauthn/browser's startAuthentication() and two dedicated backend routes.
Methods (inline in pages/index.vue) ​
typescript
// Fetch challenge (public endpoint — no auth required)
POST passkeys/login/options → PublicKeyCredentialRequestOptionsJSON
// Verify assertion and return token (same shape as social login)
POST passkeys/login (assertion body) → { access_token: string }Usage Example ​
vue
<script setup lang="ts">
import { startAuthentication, browserSupportsWebAuthn } from '@simplewebauthn/browser'
const { $api } = useNuxtApp()
async function passkeyLogin() {
const optRes = await $api.post<PublicKeyCredentialRequestOptionsJSON>('passkeys/login/options')
const optionsJSON = optRes.data ?? optRes
let assertion
try {
assertion = await startAuthentication({ optionsJSON })
} catch (err) {
if (isPasskeyCancellation(err)) return // user dismissed — stay quiet
throw err
}
const verifyRes = await $api.post<{ access_token?: string }>('passkeys/login', assertion)
const token = verifyRes.data?.access_token
sessionStorage.setItem('passkey_access_token', token)
await navigateTo({ path: '/social-login', query: { provider: 'passkey' } }, { replace: true })
}
</script>Workflows ​
Password Login ​
Google / Microsoft OAuth ​
Passkey Login ​
Passkey Registration (Profile Settings) ​
Logout ​
Logout favours a fast redirect over waiting for the API. The session is cleared locally whatever the API does, so the request only has to leave the browser.
| Rule | Value | Why |
|---|---|---|
| Request options | { method: 'POST', keepalive: true, skipCancel: true } | keepalive lets the browser finish the request after the page navigates away; skipCancel keeps it out of the cancel pool so cancelAllRequests() does not abort it |
| Client wait | LOGOUT_WAIT_MS = 300 | Promise.race with a 300 ms timer; a slow API no longer delays the redirect |
| SSR | awaits the full request | middleware such as check-workspace logs out during SSR, where there is no navigation to race against |
| API error | ignored | token and cookies are cleared in finally inside nuxtApp.runWithContext |
| Redirect | immediate window.location.replace('/') | before 38752451 logoutApp waited 5 s (setTimeout(..., 5000)) |
Introduced in 38752451 and 0907e092 (PR #116, #118).
API Integration ​
Auth Endpoints ​
| Endpoint | Method | Description |
|---|---|---|
/login | POST | Password login — returns { data: { access_token } } |
/support-login | POST | Support portal login (PIN-based) |
/logout | POST | Server-side session invalidation |
/user | GET | Fetch authenticated user data |
/get-external-user | GET | Fetch external user data (is_external sessions) |
check-branding | GET | Domain verification on login page load; also returns sso: { enabled, url_slug } |
auth/sso/resolve | POST | { email } → url_slug, workspace_name, provider, redirect_url for the SSO work-email step |
auth/sso/:url_slug/redirect | GET (browser navigation) | Starts the SAML sign-in; query return_host, return_path |
passkeys/login/options | POST | WebAuthn authentication challenge |
passkeys/login | POST | WebAuthn assertion verification |
passkeys/register/options | POST | WebAuthn registration challenge |
passkeys/register | POST | WebAuthn credential registration |
passkeys | GET | List user's registered passkeys |
passkeys/:id | DELETE | Revoke a passkey |
Login Request / Response ​
json
// POST /login
{
"email": "[email protected]",
"password": "secret"
}
// Response
{
"data": {
"access_token": "eyJ..."
}
}User Response Shape ​
json
{
"data": {
"user": {
"id": 42,
"email": "[email protected]",
"workspace_id": 7,
"is_external": false,
"accessibleWorkspaces": [
{ "id": 7, "name": "Acme Brand", "url_slug": "acme" }
],
"subscription_features": {}
}
}
}Component Integration ​
Login page (app/pages/index.vue) ​
State is owned directly by the page — there is no dedicated page composable for login. useAuth provides login, isAuthenticated, and user; useHelpers provides nextStep, setCurrentWorkspace, and getUserModulesAndRoles. Form values are a single form ref object, not separate refs per field.
vue
<script setup lang="ts">
import { startAuthentication, browserSupportsWebAuthn } from '@simplewebauthn/browser'
import type { PublicKeyCredentialRequestOptionsJSON, AuthenticationResponseJSON } from '@simplewebauthn/browser'
definePageMeta({
layout: 'login-layout',
middleware: ['guest-check'],
})
const config = useRuntimeConfig()
const { $amplitude } = useNuxtApp()
const { login, isAuthenticated, user } = useAuth()
const { nextStep, setCurrentWorkspace, getUserModulesAndRoles } = useHelpers()
const snackbar = useSnackbar()
const currentWorkspaceCookie = useCookie<{ id: number } | null>('currentWorkspace')
const form = ref<{ email: string; password: string }>({ email: '', password: '' })
const errors = ref<{ email: string | null; password: string | null; pin: string | null }>({
email: null,
password: null,
pin: null,
})
const loading = ref(false)
const contentLoading = ref(true)
const showGoogleSignIn = ref(false)
const showMicrosoftSignIn = ref(false)
const showPasskeyLogin = ref(false)
const passkeyLoading = ref(false)
// Social sign-in and passkey are shown only on the primary domain —
// hidden on white-label deployments
const isSupportPlatform = computed(() =>
config.public.isSupportPlatform === true || String(config.public.isSupportPlatform) === 'true'
)
const disableSubmitBtn = computed(() => {
const email = form.value.email || ''
const password = form.value.password || ''
return (
loading.value ||
!!errors.value.email ||
!!errors.value.password ||
!!errors.value.pin ||
!email.trim() ||
!password.trim()
)
})
onMounted(async () => {
// CSRF fetch starts first and is not awaited on its own; failures are swallowed
let csrfRequest: Promise<unknown> = Promise.resolve()
try {
const { fetchCsrfToken } = useCsrf()
csrfRequest = Promise.resolve(fetchCsrfToken()).catch(() => {})
} catch {}
// (mobile app redirect via config.public.mobileAppDomain happens here)
// check-branding verifies the domain is registered; throws 404 if not.
// Runs in parallel with the CSRF fetch (38752451)
await Promise.all([csrfRequest, verifyDomain()])
// compare window.location.origin against config.public.baseUrl
resolveSocialSignIn()
})
async function handleSubmit(): Promise<void> {
loading.value = true
try {
const loggedInUser = await login(
{ email: form.value.email, password: form.value.password },
{ isSupport: isSupportPlatform.value }
)
const workspace =
loggedInUser.accessibleWorkspaces.find((w) => w.id === loggedInUser.workspace_id) ||
loggedInUser.accessibleWorkspaces[0]
setCurrentWorkspace(workspace.id)
$amplitude?.track(AMPLITUDE_EVENTS.USER_LOGGED_IN, { method: 'local' }, { immediate: true })
snackbar.success('Login successful')
await nextStep()
} catch (err) {
snackbar.error(getApiErrorMessage(err, 'Login failed. Please try again.'))
} finally {
loading.value = false
}
}
function googleSignIn(): void {
// sessionStorage.login_provider is NOT set for Google — /social-login
// defaults to 'google' when the key is absent
window.location.href = config.public.googleAuthUrl as string
}
function microsoftSignIn(): void {
// Microsoft's callback carries no provider param; sessionStorage survives the redirect
sessionStorage.setItem('login_provider', 'microsoft')
window.location.href = config.public.microsoftAuthUrl as string
}
async function passkeyLogin(): Promise<void> {
passkeyLoading.value = true
const { $api } = useNuxtApp()
try {
const optRes = await $api.post<PublicKeyCredentialRequestOptionsJSON>('passkeys/login/options')
const optionsJSON = (optRes.data ?? optRes) as PublicKeyCredentialRequestOptionsJSON
let assertion: AuthenticationResponseJSON
try {
assertion = await startAuthentication({ optionsJSON })
} catch (err) {
if (isPasskeyCancellation(err)) return
throw err
}
const verifyRes = await $api.post<{ access_token?: string }>('passkeys/login', assertion)
const token = verifyRes.data?.access_token
if (!token) { snackbar.error('Passkey sign-in failed. Please try again.'); return }
// Token is passed via sessionStorage so it never appears in the URL / browser history
sessionStorage.setItem('passkey_access_token', token)
await navigateTo({ path: '/social-login', query: { provider: 'passkey' } }, { replace: true })
} catch (e) {
snackbar.error(getApiErrorMessage(e, 'Passkey sign-in failed. Please try again.'))
} finally {
passkeyLoading.value = false
}
}
</script>
<template>
<div>
<!-- Standard Login form -->
<div class="signin-screen-body">
<h3>Please login to your account</h3>
<v-form @submit.prevent="handleSubmit">
<v-text-field v-model="form.email" type="email" variant="outlined" density="compact"
@input="validateEmail" @blur="validateEmail" />
<div v-if="errors.email" class="form-control-error">{{ errors.email }}</div>
<v-text-field v-model="form.password" type="password" variant="outlined" density="compact"
@input="validatePass" @blur="validatePass" />
<div v-if="errors.password" class="form-control-error">{{ errors.password }}</div>
<v-btn size="large" type="submit" class="btn-primary w-100" :disabled="disableSubmitBtn">
<v-progress-circular v-if="loading" indeterminate size="14" width="2" />
Login
</v-btn>
<NuxtLink to="/forgot-password">Forgot Password</NuxtLink>
</v-form>
</div>
<!-- Social / passkey tiles — rendered only on the primary domain -->
<div v-if="showGoogleSignIn || showMicrosoftSignIn || showPasskeyLogin" class="signin-screen-with">
<div class="or-divider">or continue with</div>
<div class="social-tiles">
<button v-if="showGoogleSignIn" class="social-tile social-tile-google" @click="googleSignIn">
<AsyncIcon name="googleIcon" />
<span>Google</span>
</button>
<button v-if="showMicrosoftSignIn" class="social-tile social-tile-microsoft" @click="microsoftSignIn">
<AsyncIcon name="microsoftIcon" />
<span>Microsoft</span>
</button>
<button v-if="showPasskeyLogin" class="social-tile social-tile-passkey"
:disabled="passkeyLoading" @click="passkeyLogin">
<AsyncIcon name="passkeyIcon" />
<span>{{ passkeyLoading ? 'Waiting…' : 'Passkey' }}</span>
</button>
</div>
</div>
</div>
</template>