Appearance
Portals ​
Overview ​
Portals are branded, externally accessible hubs for distributing assets to specific audiences — agencies, retailers, sales teams, and press contacts. Each portal is a DAM instance with its own branding, URL, access controls, banners, content tiles, and user list.
- Portal List —
pages/[workspace_id]/dam/portals/index.vueshows all workspace portals as cards with stats (views, downloads, asset count), status chip (Active/Inactive), and an "Add Portal" action gated bycanCreatepermission. - Portal Detail —
pages/[workspace_id]/dam/portals/[id].vuetabs across Settings, Branding, Banners, Tiles, and Users for a single portal instance. - Portal API —
usePortalApihandles all CRUD on thedigital-assets/instanceresource — list, detail, create, update settings, update branding, visibility toggle, user listing. - Portal List Query —
usePortalsListQuerywraps the list and branding fetches in TanStack reactive queries with cache invalidation. - Branding —
PortalBrandingTaband related components allow uploading a logo, favicon, and configuring theme colors. - Banners & Tiles —
PortalBannerListandPortalTileListmanage the hero banners and content tiles displayed on the external portal.
Architecture ​
Portals map to the backend digital-assets/instance resource. The list endpoint (instance/get) returns { instances, max_instances } — the UI respects max_instances to gate portal creation. The detail endpoint (instance/detail) returns the full PortalInstance shape. Branding (logo, favicon, colors) is updated via a multipart FormData POST. Workspace-level branding (theme colors) is a separate update-workspace-branding endpoint. The external-facing portal is served from pages/[workspace_id]/external/ with a token-gated middleware; internal portal management lives under pages/[workspace_id]/dam/portals/.
File Structure ​
Vue Component Files ​
app/components/dam/portals/AddEditBannerDialog.vue— dialog for creating or editing a portal hero banner (image, title, subtitle, link)app/components/dam/portals/AddEditTileDialog.vue— dialog for creating or editing a content tile on the portal landing pageapp/components/dam/portals/PortalBannerList.vue— lists all banners for a portal; supports reordering and deleteapp/components/dam/portals/PortalBrandingTab.vue— branding panel for logo upload, favicon upload, primary/secondary color pickers, and font selectionapp/components/dam/portals/PortalTileList.vue— lists all content tiles; supports reordering and deleteapp/components/dam/portals/PortalUpgradePlanSection.vue— plan-gate prompt shown whenmax_instancesis reachedapp/components/dam/portals/StaticPlanWorkspaceBranding.vue— read-only branding section for plans that don't include branding customizationapp/components/dam/portals/CNameGuideDialog.vue— step-by-step guide for configuring a custom CNAME for the portal URLapp/components/dam/portals/InvitePortalUserDialog.vue— invite dialog for adding external users to a portalapp/components/dialogs/CreatePortalDialog.vue— dialog for creating a new portal (name input, initial settings)app/components/dialogs/PortalLimitDialog.vue— modal shown when the workspace has reached its portal limit
Composable Files ​
app/composables/api/usePortalApi.ts— full CRUD on portal instances:listPortals,getPortalDetail,createPortal,getInstanceList,updatePortalSettings,updatePortalBranding,updateInstance,updateInstanceSettings,updateWorkspaceBranding,changePortalVisibility,getSubscription,getPortalUsers,getBranding,getWorkspaceDetailapp/composables/api/usePortalBannerApi.ts— banner CRUD: list, create, update, delete, reorderapp/composables/api/usePortalTileApi.ts— tile CRUD: list, create, update, delete, reorderapp/composables/api/usePortalUsersApi.ts— user management: list portal users, invite, remove, update rolesapp/composables/api/usePortalUsersSettingsApi.ts— per-portal user permission settingsapp/composables/queries/usePortalsListQuery.ts— TanStack reactive query for portal list and workspace branding; exposesinvalidatePortalsListapp/composables/queries/usePortalBannersQuery.ts— reactive query for banners of a specific portalapp/composables/queries/usePortalTilesQuery.ts— reactive query for tiles of a specific portalapp/composables/queries/usePortalDetailUsersQuery.ts— reactive query for users of a specific portalapp/composables/queries/usePermissionPortalsQuery.ts— reactive query for portals used in collage/asset permission dialogsapp/composables/queries/usePortalUsersSettingsMutations.ts— TanStack mutations for portal user settings
Page Files ​
app/pages/[workspace_id]/dam/portals/index.vue— portal list page; card layout with status chip, URL, stats, and "Add Portal" buttonapp/pages/[workspace_id]/dam/portals/[id].vue— portal detail page with tabbed interface: Settings, Branding, Banners, Tiles, Users
Routes:
/:workspace_id/dam/portals— portal list/:workspace_id/dam/portals/:id— portal detailapp/pages/[workspace_id]/dam/add-instance.vue— first-run DAM instance setup page; shown when no DAM instance exists for the workspace
External Access Pages ​
app/pages/[workspace_id]/external/request-access.vue— request-access form for portals with restricted accessapp/pages/[workspace_id]/external/request-submitted.vue— confirmation page after access requestapp/pages/[workspace_id]/external/upload.vue— external contributor upload pageapp/pages/[workspace_id]/external/verify.vue— email/OTP verification for external users
Shared Assets Pages (Public Viewer) ​
app/pages/shared-assets/[type].vue— unauthenticated public share viewer; no layout, no middleware; delegates entirely to<SharedAssetsPage />app/pages/[brand_name]/shared-assets/[type].vue— brand-prefixed variant of the public share viewer (white-label domains); same structure as above
Type Files ​
app/types/portal.ts—PortalListData,PortalInstance,PortalCreatePayload,PortalSubscription,BrandingUpdatePayload,PortalVisibilityPayload,InstanceSettingsUpdatePayload,PortalUserListQuery
Page Details ​
Portal List ([workspace_id]/dam/portals/index.vue) ​
| Field | Value |
|---|---|
| Route | /:workspace_id/dam/portals |
| Layout | collage-layout (embedsHeader: true) |
| Middleware | auth-check, check-workspace, can-access-dam-module, check-if-suspended |
| Primary composable | usePortalsList() |
Displays portal cards showing brand name, active/inactive status chip, public/private lock icon, asset/folder/collage counts (with optional plan limits), user count (private portals only), last-updated time, and top tags. Clicking a card navigates to the detail page if the user has access; clicking the URL opens the portal externally.
Dialogs (both triggered by the "Add Portal" button, controlled by addPortalDialog)
| Dialog | When shown |
|---|---|
PortalLimitDialog | User has reached their portal plan limit (isAtPortalLimit) |
CreatePortalDialog | User is within plan limits |
Portal Detail ([workspace_id]/dam/portals/[id].vue) ​
| Field | Value |
|---|---|
| Route | /:workspace_id/dam/portals/:id |
| Layout | collage-layout |
| Middleware | auth-check, check-workspace, can-access-dam-module, check-if-suspended, check-workspace-access |
| Primary composable | usePortalDetail() |
Renders a tab bar whose visible tabs are permission-controlled.
| Tab | Component | Gate |
|---|---|---|
| Customize | PortalBrandingTab | always visible |
| Users | DamSettingsListView with scroll-based infinite scroll | canManageUsers |
| Content Blocks | PortalBannerList + PortalTileList | canManageBanners || canManageTiles |
Child tab components manage their own dialogs internally — no top-level dialogs are rendered by the portal detail page itself.
Add Instance ([workspace_id]/dam/add-instance.vue) ​
| Field | Value |
|---|---|
| Route | /:workspace_id/dam/add-instance |
| Layout | login-layout |
| Middleware | auth-check, check-workspace, check-dam-instance |
| Primary composable | useAddDamInstance() |
Shown on first run when a workspace has no DAM instance configured. Renders a centered card with a Company Name field and a domain type radio: Collage subdomain (free) vs Custom domain (subscription-gated). Selecting custom domain shows the domain input; non-qualifying plans see an upgrade prompt in place of the input.
Shared Assets Viewer (shared-assets/[type].vue and [brand_name]/shared-assets/[type].vue) ​
| Field | Value |
|---|---|
| Route (default) | /shared-assets/:type |
| Route (branded) | /:brand_name/shared-assets/:type |
| Layout | false (no layout wrapper) |
| Middleware | none |
| Primary composable | none — page delegates entirely to <SharedAssetsPage /> |
These pages are intentionally thin wrappers. The branded variant is used by white-label domains where the brand name prefix is part of the URL. Both render the same <SharedAssetsPage /> component; no auth is required and no Nuxt layout is applied.
usePortalApi Composable ​
File: app/composables/api/usePortalApi.ts
All portal instance operations. Every method is wrapped with useApiRequestState().track(). Response shapes are normalized — the API may return the data directly or wrapped in { data: ... }; the composable handles both envelopes transparently.
Methods ​
typescript
// List all portal instances for a workspace
listPortals(workspaceId: number | string): Promise<PortalListData>
// PortalListData: { instances: PortalInstance[], max_instances: number | null }
// Fetch full detail for one portal
getPortalDetail(workspaceId: number | string, instanceId: number | string): Promise<PortalInstance | null>
// Create a new portal
createPortal(payload: PortalCreatePayload | FormData): Promise<ApiEnvelope<PortalInstance>>
// Minimal list (id + name) for dropdowns
getInstanceList(workspaceId: number | string): Promise<PortalInstance[]>
// Update non-file portal settings (name, URL, access type, etc.)
updatePortalSettings(payload: UpdatePortalSettingsPayload): Promise<ApiEnvelope<PortalInstance>>
// Update portal settings via multipart FormData (hits digital-assets/instance/update-settings,
// same endpoint as updatePortalSettings but with FormData body)
// Note: logo/favicon uploads go through updateInstance → digital-assets/instance/update instead
updatePortalBranding(formData: FormData): Promise<ApiEnvelope<PortalInstance>>
// Update DAM instance metadata (name, url, logo, favicon) via FormData
// → POST digital-assets/instance/update
updateInstance(formData: FormData): Promise<ApiEnvelope<PortalInstance>>
// Toggle a single instance setting (trending, recently-added)
updateInstanceSettings(payload: InstanceSettingsUpdatePayload): Promise<ApiEnvelope<PortalInstance>>
// Persist workspace theme colors
updateWorkspaceBranding(payload: BrandingUpdatePayload): Promise<ApiEnvelope<unknown>>
// Toggle public-portal visibility (no-auth access)
changePortalVisibility(payload: PortalVisibilityPayload): Promise<ApiEnvelope<unknown>>
// Subscription details (max_instances, plan features)
getSubscription(): Promise<PortalSubscription>
// Portal user list with pagination
getPortalUsers(query: PortalUserListQuery): Promise<Record<string, unknown>>
// Workspace branding (logo, colors, white_label_enabled)
getBranding(workspaceId: number | string): Promise<{ white_label_enabled?: boolean; [key: string]: unknown }>
// Workspace detail
getWorkspaceDetail(workspaceId: number | string): Promise<Record<string, unknown>>Usage ​
vue
<script setup lang="ts">
const { listPortals, createPortal, updatePortalSettings, isLoading } = usePortalApi()
const { getWorkspaceId } = useWorkspaceId()
const portals = await listPortals(getWorkspaceId())
const onCreate = async (name: string) => {
await createPortal({ brand_name: name, workspace_id: getWorkspaceId() })
}
const onUpdateSettings = async (instanceId: number, settings: Record<string, unknown>) => {
await updatePortalSettings({
workspace_id: getWorkspaceId(),
instance_id: instanceId,
...settings,
})
}
</script>usePortalsListQuery Composable ​
File: app/composables/queries/usePortalsListQuery.ts
Two reactive TanStack queries for the portal list page: one for the portal list itself, one for workspace branding. Both are keyed on workspaceId and enabled only when it's available. The workspaceId is resolved from route.params.workspace_id with fallback to the useWorkspaceId() composable.
Returns ​
typescript
{
portalsQuery: UseQueryReturnType<PortalListData, Error>
brandingQuery: UseQueryReturnType<{ white_label_enabled?: boolean; [key: string]: unknown }, Error>
invalidatePortalsList: () => Promise<void>
}Usage ​
vue
<script setup lang="ts">
const { portalsQuery, brandingQuery, invalidatePortalsList } = usePortalsListQuery()
const instances = computed(() => portalsQuery.data.value?.instances ?? [])
const maxInstances = computed(() => portalsQuery.data.value?.max_instances ?? null)
const canCreate = computed(() => maxInstances.value === null || instances.value.length < maxInstances.value)
</script>Portal Creation Workflow ​
1. User clicks "Add Portal" on the portal list page
Component: dialogs/CreatePortalDialog.vue
- User enters portal name
↓
2. Dialog submits
Composable: usePortalApi.createPortal({ brand_name, workspace_id })
Endpoint: POST digital-assets/instance/create
↓
3. On success
- usePortalsListQuery.invalidatePortalsList() triggers refetch
- Router pushes to /:workspace_id/dam/portals/:newId
↓
4. Portal detail page loads
Page: pages/[workspace_id]/dam/portals/[id].vue
- Settings tab opens by default
- User configures portal URL, access type, brandingPortal Settings & Branding Workflow ​
1. User navigates to portal detail page
Route: /:workspace_id/dam/portals/:id
↓
2. Settings tab
- Portal name, URL slug, access type (public / password / request-access)
- Trending and recently-added toggles
Composable: usePortalApi.updatePortalSettings / updateInstanceSettings
Endpoint: POST/PUT digital-assets/instance/update-settings
↓
3. Branding tab
Component: PortalBrandingTab.vue
- Logo and favicon upload via FormData
Composable: usePortalApi.updateInstance
Endpoint: POST digital-assets/instance/update
- Theme colors (primary, secondary)
Composable: usePortalApi.updateWorkspaceBranding
Endpoint: POST update-workspace-branding
↓
4. Visibility toggle
Composable: usePortalApi.changePortalVisibility
Endpoint: POST digital-assets/instance/change-portal-visibilityPortal Access & External View Workflow ​
1. External user visits the portal URL (e.g. brand.collage.inc/portal-slug)
↓
2. Middleware checks portal access type
- Public → render portal immediately
- Password-protected → render password gate
- Request-access → render request form (external/request-access.vue)
↓
3. Password-protected flow
- User enters password
- On success: token stored in sessionStorage
- Requests carry token for subsequent API calls
↓
4. Request-access flow
- User submits email via external/request-access.vue
- Workspace admin receives notification and approves
- User receives email with access link → external/verify.vue
↓
5. Authenticated external user browses portal
- Can view assets, preview media
- Download gated by portal's download-allowed settingBanner & Tile Management ​
1. Admin opens Banners tab on portal detail page
Component: PortalBannerList.vue
Composable: usePortalBannersQuery
↓
2. Admin clicks "Add Banner"
Component: AddEditBannerDialog.vue
- Uploads image, sets title, subtitle, and optional link URL
Composable: usePortalBannerApi
↓
3. Banners reordered via drag-and-drop
- usePortalBannerApi reorder endpoint called on drop
↓
4. Same flow for Tiles tab
Components: PortalTileList.vue, AddEditTileDialog.vue
Composable: usePortalTileApiAPI Integration ​
List Portals ​
Endpoint: GET digital-assets/instance/get
Query Parameters: url_workspace_id, workspace_id
Response:
json
{
"data": {
"instances": [
{
"id": 3,
"brand_name": "Retailer Hub",
"portal_url": "retailer-hub",
"is_active": true,
"assets_count": 142,
"views": 580,
"downloads": 43
}
],
"max_instances": 5
}
}Create Portal ​
Endpoint: POST digital-assets/instance/create
Request:
json
{ "brand_name": "Press Room", "workspace_id": 5 }Response:
json
{ "data": { "id": 7, "brand_name": "Press Room", "is_active": false }, "message": "Instance created" }Update Portal Settings ​
Endpoint: POST digital-assets/instance/update-settings
Request:
json
{
"workspace_id": 5,
"instance_id": 7,
"portal_url": "press-room",
"access_type": "password",
"portal_password": "secret123",
"download_allowed": true
}Toggle Visibility ​
Endpoint: POST digital-assets/instance/change-portal-visibility
Request:
json
{ "instance_id": 7, "workspace_id": 5, "is_active": true }Get Portal Users ​
Endpoint: GET digital-assets/instance/get-user
Query Parameters: instance_id, workspace_id, page, per_page
Component Integration ​
Both portal pages are driven by a facade composable. Pages destructure everything they need and pass values directly to components.
Portal List Page (pages/[workspace_id]/dam/portals/index.vue) ​
vue
<template>
<div>
<!-- Header: "Add Portal" button gated by canCreate -->
<v-btn v-if="canCreate" @click="openAddPortal">Add Portal</v-btn>
<!-- Inline portal card rendering — no separate PortalCard component -->
<div
v-for="instance in instances"
:key="instance.id"
class="portal-card"
@click="navigateToSettings(instance)"
>
<h5>{{ instance.brand_name }}</h5>
<v-chip :class="instance.is_active ? 'chip-published' : 'chip-disabled'">
{{ instance.is_active ? 'Active' : 'Inactive' }}
</v-chip>
<div class="portal-card-url" @click.stop="openPortal(instance)">
<span>{{ getPortalUrl(instance) }}</span>
</div>
</div>
<!-- Dialogs: PortalLimitDialog shown when isAtPortalLimit, CreatePortalDialog otherwise -->
<PortalLimitDialog v-if="isAtPortalLimit" :dialog="addPortalDialog" :portal-limit="portalLimit ?? 0" @close="addPortalDialog = false" />
<CreatePortalDialog v-else :dialog="addPortalDialog" :white-label-enabled="whiteLabelEnabled" @close="addPortalDialog = false" @created="onPortalCreated" />
</div>
</template>
<script setup lang="ts">
// usePortalsList is the facade composable for the list page — it wraps
// usePortalsListQuery, useHelpers permission checks, and all navigation/dialog state.
const {
contentLoading,
portalLimit,
addPortalDialog,
scrollContainer,
pageScrolling,
handleScroll,
instances,
whiteLabelEnabled,
canCreate,
canManageSettings,
canAccessPortalDetail,
isAtPortalLimit,
formatNumber,
getPortalUrl,
openPortal,
navigateToSettings,
timeAgo,
onPortalCreated,
openAddPortal,
} = usePortalsList()
</script>Portal Detail Page (pages/[workspace_id]/dam/portals/[id].vue) ​
vue
<template>
<div>
<!-- Tab bar: labels computed from permissions (Customize, Users, Content Blocks) -->
<v-tabs v-model="tab">
<v-tab v-for="(label, index) in tabLabels" :key="index" :value="index">
{{ label }}
</v-tab>
</v-tabs>
<v-window v-model="tab">
<!-- Customize tab -->
<PortalBrandingTab
v-if="tabLabels[tab] === 'Customize'"
:instance="instance"
:content-loading="contentLoading"
:custom-brand-allowed="customBrandAllowed"
:is-branded="whiteLabelEnabled"
@updated="onInstanceUpdated"
/>
<!-- Users tab -->
<DamSettingsListView
v-else-if="tabLabels[tab] === 'Users' && portalUserItems.length"
:items="portalUserItems"
:columns="portalUserColumns"
:loading-more="usersLoadMore"
@sort-change="onSortChange"
@scroll="onPortalUsersScroll"
/>
<!-- Content Blocks tab -->
<template v-else-if="tabLabels[tab] === 'Content Blocks'">
<PortalBannerList v-if="canManageBanners" :instance-id="instance.id" />
<PortalTileList v-if="canManageTiles" :instance="instance" />
</template>
</v-window>
</div>
</template>
<script setup lang="ts">
// usePortalDetail is the facade composable for the detail page — it bootstraps
// instance data via useAsyncData, manages tab state, users query, and permissions.
const {
contentLoading,
tab,
instance,
tabLabels,
portalDisplayUrl,
canShowPortalUrl,
canManageUsers,
canManageBanners,
canManageTiles,
customBrandAllowed,
whiteLabelEnabled,
users,
fetchingUsers,
usersLoadMore,
sortValue,
sortReverse,
fetchInstance,
fetchUsers,
sortByColumn,
goBackToPortals,
goToManageUsers,
openPortal,
} = usePortalDetail()
</script>