Appearance
Authentication Methods ​
Overview ​
Collage Admin supports four 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.
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, calls the check-branding API to verify the current domain is whitelisted — 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. 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. Tracks success and failure paths in Amplitude.
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
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 — calls /logout, 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 | nullUsage 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 ​
pages/index.vue
→ validateEmail() + validatePass()
→ useAuth.login({ email, password })
→ POST /login
→ store token: auth_token cookie (primary) + useState + localStorage (legacy fallback)
→ fetchUser(forceRefresh=true)
→ resolveCurrentWorkspace()
→ damStore.setWorkspaceId()
→ damStore.fetchWorkspaceBranding()
→ check referring_url_data cookie (session-expiry redirect)
→ navigateTo workspace DAM
→ Amplitude: USER_LOGGED_IN { method: 'local' }Google / Microsoft OAuth ​
pages/index.vue
→ googleSignIn() / microsoftSignIn()
→ window.location.href = config.public.googleAuthUrl / microsoftAuthUrl
[browser redirects to provider]
[user consents]
[provider redirects to backend callback]
[backend creates/links account, returns JWT]
→ backend redirects to /social-login?access_token=...
pages/social-login.vue
→ read ?access_token
→ set auth_token cookie
→ fetchUser()
→ resolveCurrentWorkspace()
→ nextStep()
→ Amplitude: USER_LOGGED_IN { method: 'google' | 'microsoft' }Passkey Login ​
pages/index.vue
→ passkeyLogin()
→ POST passkeys/login/options → challenge
→ startAuthentication({ optionsJSON: challenge })
[browser prompts Face ID / fingerprint / security key]
→ POST passkeys/login (assertion)
→ token → sessionStorage.passkey_access_token
→ navigateTo /social-login?provider=passkey
pages/social-login.vue
→ read sessionStorage.passkey_access_token (consume + clear)
→ set auth_token cookie
→ fetchUser()
→ nextStep()
→ Amplitude: USER_LOGGED_IN { method: 'passkey' }Passkey Registration (Profile Settings) ​
Account Settings → Security tab
→ "Add Passkey" button
→ usePasskeyApi.getRegisterOptions() → challenge
→ startRegistration({ optionsJSON: challenge })
[browser prompts authenticator]
→ usePasskeyApi.registerPasskey({ ...credential, alias })
→ refresh passkey list via getPasskeys()
→ Amplitude: passkey registeredAPI 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 |
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 () => {
// check-branding verifies the domain is registered; throws 404 if not
await 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>