Appearance
External Access ​
Overview ​
External access covers two distinct entry points that let unauthenticated parties interact with a workspace without a Collage account:
- 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.
- 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.
- 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-accessAPI to confirm the session is still valid - On 401/402/403/404 or
has_access: false→ clears cookies and redirects torequest-access - Client-only (skips SSR to avoid blank-origin rejections)
- Calls
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_slugagainst routeworkspace_id
app/middleware/external-otp-verify.ts— OTP verify page guard- Already active external user → redirect to upload
- Missing or expired
verifyEmailcookie (> 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.serverearly 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
verifyEmailcookie 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_tokencookie andauth-userstate, navigates to upload
- 6-digit OTP input (
app/pages/[workspace_id]/external/upload.vue— upload page- Renders
AssetUploadwithmode="file" flag="external" - Middleware:
check-external-access,external-upload-auth - Shows
ExternalLogowith workspace branding; logout button prevents redirect during active upload
- Renders
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
logoprop is set; falls back to Collage wordmark
- Renders branded logo when
Composable Files ​
app/composables/api/useExternalUsersApi.ts— external user management APIlistExternalUsers(params)— paginated list with optionaltypefilteraddExternalUser(payload)— create an external upload link recipienteditExternalUser(payload)— update email or access detailstoggleExternalUserStatus(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) ​
| Field | Value |
|---|---|
| Route | /:workspace_id/external/request-access |
| Layout | false |
| Middleware | external-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) ​
| Field | Value |
|---|---|
| Route | /:workspace_id/external/verify |
| Layout | false |
| Middleware | external-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) ​
| Field | Value |
|---|---|
| Route | /:workspace_id/external/upload |
| Layout | false |
| Middleware | check-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) ​
| Field | Value |
|---|---|
| Route | /:workspace_id/external/request-submitted |
| Layout | false |
| Middleware | check-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 ​
| Method | Endpoint | Description |
|---|---|---|
| GET | /external-user/get?workspace_id=&type= | List external users (optional type filter) |
| POST | /external-user/add | Create external upload recipient |
| POST | /external-user/edit | Edit 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.
| Page | Middleware | Guard Purpose |
|---|---|---|
request-access.vue | external-guest-redirect | Skip form if already authenticated |
verify.vue | external-otp-verify | Require valid verifyEmail cookie |
upload.vue | check-external-access, external-upload-auth | Validate server-side access; require active token |
request-submitted.vue | external-upload-auth | Require 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 completedAccess 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 emailAPI Integration ​
External Upload API Endpoints ​
| Endpoint | Method | Description |
|---|---|---|
/external-user/get | GET | List external users for a workspace |
/external-user/add | POST | Create external upload recipient |
/external-user/edit | POST | Edit or toggle external user status |
check-external-access | GET | Validate 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>