Skip to content

External Access ​

Overview ​

External access covers two distinct entry points that let unauthenticated parties interact with a workspace without a Collage account:

  1. External Upload — workspace members generate a tokenized URL scoped to a folder; external users open the link, verify their email via a 6-digit OTP, and upload files directly into that folder using the same chunked S3 pipeline as internal uploads.
  2. Access Requests — external parties visiting a restricted portal can submit their email to request access; workspace admins approve or deny from notification dropdowns or portal settings.
  3. External User Management — workspace admins view, toggle, and manage all external users (upload recipients and portal visitors) from a dedicated settings page.

All external pages operate outside the main DAM layout. The layout: false directive is set on each external page, and a bespoke five-middleware guard chain controls access at every step of the OTP flow.

Architecture ​

The external upload flow is governed by four route-level middleware that enforce state transitions in a strict sequence: the email-request page → the OTP-verify page → the upload page. Any deviation (missing OTP cookie, wrong workspace, already-authenticated user) redirects immediately rather than rendering a broken state.

Token storage for external sessions mirrors the internal auth pattern: auth_token cookie + useState('auth-token-mem') memory. A separate short-lived verifyEmail cookie (10-minute expiry) bridges the gap between email submission and OTP verification; the external-otp-verify middleware checks its expiry client-side before rendering the verify form.

UploadChunkExternal.vue is a display-only list item component; the actual upload mechanics are delegated to the AssetUpload component rendered on the upload page with mode="file" and flag="external".

File Structure ​

Middleware Files ​

  • app/middleware/check-external-access.ts — access validation for authenticated external users

    • Calls check-external-access API to confirm the session is still valid
    • On 401/402/403/404 or has_access: false → clears cookies and redirects to request-access
    • Client-only (skips SSR to avoid blank-origin rejections)
  • app/middleware/external-guest-redirect.ts — request-access page guard

    • Already authenticated active external user for this workspace → redirect straight to upload
    • Compares user.workspace_url_slug against route workspace_id
  • app/middleware/external-otp-verify.ts — OTP verify page guard

    • Already active external user → redirect to upload
    • Missing or expired verifyEmail cookie (> 10 min) → redirect to request-access
    • Client-only cookie check (SSR skipped)
  • app/middleware/external-upload-auth.ts — upload and request-submitted page guard

    • No token or inactive user → redirect to verify page
    • Active user on wrong workspace → redirect to request-access for that workspace
    • Client-only (import.meta.server early return)

Page Files ​

  • app/pages/[workspace_id]/external/request-access.vue — email entry page

    • Email form that sends a verification code to the external user
    • Middleware: external-guest-redirect
    • Sets the verifyEmail cookie with a 10-minute expiry on success
  • app/pages/[workspace_id]/external/verify.vue — OTP verification page

    • 6-digit OTP input (v-otp-input, length 6)
    • Middleware: external-otp-verify
    • On success: sets auth_token cookie and auth-user state, navigates to upload
  • app/pages/[workspace_id]/external/upload.vue — upload page

    • Renders AssetUpload with mode="file" flag="external"
    • Middleware: check-external-access, external-upload-auth
    • Shows ExternalLogo with workspace branding; logout button prevents redirect during active upload
  • app/pages/[workspace_id]/external/request-submitted.vue — confirmation page

    • Shown after access request is submitted to a restricted portal
    • Middleware: external-upload-auth

Component Files ​

  • app/components/asset/UploadChunkExternal.vue — upload queue list item

    • Displays a single queued file: thumbnail preview, filename, file size, remove button
    • Does not contain upload logic; upload is owned by AssetUpload
  • app/components/dialogs/ExternalUserDialog.vue — external user detail dialog

    • Shows external user info, last-access timestamp
    • Actions: toggle active status, resend invite
  • app/components/global/ExternalLogo.vue — workspace logo for external pages

    • Renders branded logo when logo prop is set; falls back to Collage wordmark

Composable Files ​

  • app/composables/api/useExternalUsersApi.ts — external user management API
    • listExternalUsers(params) — paginated list with optional type filter
    • addExternalUser(payload) — create an external upload link recipient
    • editExternalUser(payload) — update email or access details
    • toggleExternalUserStatus(payload) — activate or deactivate access

Page Details ​

All four external pages use layout: false (no Nuxt layout wrapper) and fetch workspace branding from external-brand?workspace_id= on mount, rendering either a branded logo or the Collage fallback via ExternalLogo.

Request Access ([workspace_id]/external/request-access.vue) ​

FieldValue
Route/:workspace_id/external/request-access
Layoutfalse
Middlewareexternal-guest-redirect

Email form with inline validation. POSTs to external-login; on success writes a verifyEmail cookie (10-minute TTL with { value, expiresAt }) and navigates to /verify. If an OTP was already sent, also redirects to verify. The isBranded flag hides Collage decorative elements and the "Powered by" footer on white-label deployments.


Verify OTP ([workspace_id]/external/verify.vue) ​

FieldValue
Route/:workspace_id/external/verify
Layoutfalse
Middlewareexternal-otp-verify

6-digit v-otp-input (auto-uppercases input). Reads the email from the URL query param or the verifyEmail cookie. POSTs to external-login-verify; on success calls applySession(token, 'external'), sets auth-user state, and navigates to /upload. Redirects back to request-access if no email can be resolved.


External Upload ([workspace_id]/external/upload.vue) ​

FieldValue
Route/:workspace_id/external/upload
Layoutfalse
Middlewarecheck-external-access, external-upload-auth

Renders <AssetUpload mode="file" flag="external" />. Logout button POSTs to external-logout, clears auth_token and verifyEmail cookies, and clears auth-user state. The prohibitRedirect flag (set via stop-external-upload mitt event) blocks logout and shows a tooltip while an upload is in flight. window.onbeforeunload is active while an upload is running.


Request Submitted ([workspace_id]/external/request-submitted.vue) ​

FieldValue
Route/:workspace_id/external/request-submitted
Layoutfalse
Middlewarecheck-external-access, external-upload-auth

Validates the EXTERNAL_SUBMITTED_QUERY_KEY query param on mount; redirects to /upload if it is absent. "Submit New Request" navigates back to request-access. Logout follows the same pattern as the upload page (POST + cookie/state clear).


UploadChunkExternal ​

File: app/components/asset/UploadChunkExternal.vue

Display component for a single file in the external upload queue. Reuses the same thumbnail logic as internal upload list items.

Props ​

typescript
interface Props {
  file: UploadFileItem  // file name, size, src thumbnail, isEmptyDir flag
}

Events ​

typescript
{
  delete: []  // emitted when user clicks the remove (X) button
}

Usage Example ​

vue
<template>
  <UploadChunkExternal
    v-for="file in queuedFiles"
    :key="file.id"
    :file="file"
    @delete="removeFromQueue(file.id)"
  />
</template>

<script setup lang="ts">
import type { UploadFileItem } from '~/types/upload'
const queuedFiles = ref<UploadFileItem[]>([])

function removeFromQueue(id: string) {
  queuedFiles.value = queuedFiles.value.filter(f => f.id !== id)
}
</script>

useExternalUsersApi ​

File: app/composables/api/useExternalUsersApi.ts

Admin-side API for managing external users from workspace settings. All methods are request-tracked via useApiRequestState.

Methods ​

typescript
{
  listExternalUsers: (params: ExternalUserListParams) => Promise<ApiResponse<ExternalUserListResponse>>
  addExternalUser: (payload: AddExternalUserPayload) => Promise<ApiResponse>
  editExternalUser: (payload: EditExternalUserPayload) => Promise<ApiResponse>
  toggleExternalUserStatus: (payload: ToggleExternalUserPayload) => Promise<ApiResponse>
  isLoading: Ref<boolean>
  error: Ref<unknown>
}

API Endpoints ​

MethodEndpointDescription
GET/external-user/get?workspace_id=&type=List external users (optional type filter)
POST/external-user/addCreate external upload recipient
POST/external-user/editEdit or toggle external user

Usage Example ​

vue
<script setup lang="ts">
const { listExternalUsers, toggleExternalUserStatus, isLoading } = useExternalUsersApi()

const users = ref([])

onMounted(async () => {
  const response = await listExternalUsers({ workspace_id: workspaceId, type: 0 })
  users.value = response.data?.users ?? []
})

async function revokeUser(userId: number) {
  await toggleExternalUserStatus({ id: userId, is_active: false, workspace_id: workspaceId })
}
</script>

Middleware Guard Chain ​

Each external page declares one or more middleware in definePageMeta. The middleware run in the order listed.

PageMiddlewareGuard Purpose
request-access.vueexternal-guest-redirectSkip form if already authenticated
verify.vueexternal-otp-verifyRequire valid verifyEmail cookie
upload.vuecheck-external-access, external-upload-authValidate server-side access; require active token
request-submitted.vueexternal-upload-authRequire active external token
typescript
// example: upload page middleware declaration
definePageMeta({
  layout: false,
  middleware: ['check-external-access', 'external-upload-auth'],
})

Workflows ​

External Upload Flow ​

Workspace member generates upload link → scoped token URL
  ↓
External user opens URL
  → pages/[workspace_id]/external/request-access.vue
  → external-guest-redirect middleware (skip if already authed)
  → form: enter email → POST to backend → OTP sent
  → verifyEmail cookie set (10-min window)
  ↓
pages/[workspace_id]/external/verify.vue
  → external-otp-verify middleware (redirect if cookie expired)
  → v-otp-input: enter 6-digit code
  → POST to backend → verify OTP
  → auth_token cookie set, auth-user state hydrated
  ↓
pages/[workspace_id]/external/upload.vue
  → check-external-access middleware (server-side session validation)
  → external-upload-auth middleware (token + workspace check)
  → AssetUpload mode="file" flag="external"
  → files → chunked S3 multipart upload
  → assets land in target folder
  → Amplitude: external upload completed

Access Request Flow (Restricted Portal) ​

Visitor opens restricted portal URL
  → portal page detects request_access type
  → redirect to pages/[workspace_id]/external/request-access.vue
  → submit name + email → backend sends notification to admin
  → navigate to request-submitted.vue
  ↓
Workspace admin receives notification
  → approves/denies from notification dropdown or portal settings
  → on approval: backend sends tokenized access link via email

API Integration ​

External Upload API Endpoints ​

EndpointMethodDescription
/external-user/getGETList external users for a workspace
/external-user/addPOSTCreate external upload recipient
/external-user/editPOSTEdit or toggle external user status
check-external-accessGETValidate current external session (used by middleware)

List External Users ​

json
// GET /external-user/get?workspace_id=7&type=0
// Response
{
  "data": {
    "users": [
      {
        "id": 12,
        "email": "[email protected]",
        "is_active": true,
        "last_accessed_at": "2025-08-01T14:22:00Z",
        "workspace_url_slug": "acme"
      }
    ],
    "total": 1
  }
}

Toggle External User Status ​

json
// POST /external-user/edit
{
  "id": 12,
  "is_active": false,
  "workspace_id": 7
}

Component Integration ​

External Upload Page (app/pages/[workspace_id]/external/upload.vue) ​

The upload page fetches workspace branding on mount, renders the branded logo, and delegates all upload mechanics to AssetUpload. A $mittBus listener tracks in-progress state to block logout during active uploads.

vue
<template>
  <v-app>
    <ExternalLogo :logo="branding?.logo ?? null" :name="branding?.name ?? null" />
    <AssetUpload mode="file" flag="external" @uploaded="onAssetUploaded" />
  </v-app>
</template>

<script setup lang="ts">
import type { ExternalBranding } from '~/types/external'

definePageMeta({
  layout: false,
  middleware: ['check-external-access', 'external-upload-auth'],
})

const route = useRoute()
const { $api, $mittBus } = useNuxtApp()

const workspaceId = computed(() => route.params.workspace_id as string)
const branding = ref<ExternalBranding | null>(null)
const prohibitRedirect = ref(false)

async function fetchBrandData(): Promise<void> {
  const response = await $api(`external-brand?workspace_id=${workspaceId.value}`, { method: 'GET' })
  branding.value = (response?.data ?? response) as ExternalBranding
}

function onAssetUploaded(): void {
  // upload complete — AssetUpload manages its own success state
}

onMounted(async () => {
  await fetchBrandData()
  $mittBus?.on('stop-external-upload', (payload: unknown) => {
    const p = payload as { msg?: string; prohibitRedirect?: boolean }
    prohibitRedirect.value = p?.prohibitRedirect ?? false
  })
})
</script>

External User Management Page (app/pages/[workspace_id]/workspace-settings/external-user/list.vue) ​

All state — user list, dialog flags, type filter, URL copy — is owned by the useExternalUsersPage() facade composable. The page builds per-row menu items locally via buildExternalUserMenu() and calls toggleExternalUserStatus() from useExternalUsersApi() directly for activate/deactivate.

vue
<template>
  <div class="collage-body account-settings">
    <DamSettingsListView
      :items="externalUserItems"
      :columns="externalUserColumns"
      :resolve-menu-items="(item) => buildExternalUserMenu(item)"
      @menu-click="handleMenuClick"
    />
    <ExternalUserDialog
      :dialog="userDialog"
      :user="userToEdit"
      :flag="userDialogFlag"
      @added="handleUserSaved"
      @update="handleUserSaved"
      @update:dialog="userDialog = $event"
    />
    <ConfirmationDialog
      :dialog="deleteDialog"
      :heading="deleteDialogTitle"
      :msg="deleteDialogMessage"
      @confirm="handleToggleStatus"
      @cancel="deleteDialog = false"
    />
  </div>
</template>

<script setup lang="ts">
const {
  users,
  userDialog,
  userToEdit,
  userDialogFlag,
  selectedUserType,
  userListType,
  handleUserSaved,
  editUser,
  addUser,
  copyGuestUrl,
  resetFilter,
  changeUserTypeFilter,
} = useExternalUsersPage()

const { toggleExternalUserStatus } = useExternalUsersApi()
const route = useRoute()

const deleteDialog = ref(false)
const userToToggle = ref<Record<string, unknown> | null>(null)

function handleMenuClick(item: FolderAssetItem, menuItem: DamMenuItem) {
  switch (menuItem.key) {
    case 'edit': editUser(findExternalUserById(item.id)!); break
    case 'deactivate':
    case 'activate':
      userToToggle.value = item as unknown as Record<string, unknown>
      deleteDialog.value = true
      break
  }
}

async function handleToggleStatus() {
  deleteDialog.value = false
  const user = userToToggle.value
  if (!user?.id) return
  await toggleExternalUserStatus({
    workspace_id: route.params.workspace_id as string,
    id: Number(user.id),
    is_active: user.is_active ? 0 : 1,
  })
  await resetFilter()
}
</script>