Skip to content

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.

  1. Portal List — pages/[workspace_id]/dam/portals/index.vue shows all workspace portals as cards with stats (views, downloads, asset count), status chip (Active/Inactive), and an "Add Portal" action gated by canCreate permission.
  2. Portal Detail — pages/[workspace_id]/dam/portals/[id].vue tabs across Settings, Branding, Banners, Tiles, and Users for a single portal instance.
  3. Portal API — usePortalApi handles all CRUD on the digital-assets/instance resource — list, detail, create, update settings, update branding, visibility toggle, user listing.
  4. Portal List Query — usePortalsListQuery wraps the list and branding fetches in TanStack reactive queries with cache invalidation.
  5. Branding — PortalBrandingTab and related components allow uploading a logo, favicon, and configuring theme colors.
  6. Banners & Tiles — PortalBannerList and PortalTileList manage 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 page
  • app/components/dam/portals/PortalBannerList.vue — lists all banners for a portal; supports reordering and delete
  • app/components/dam/portals/PortalBrandingTab.vue — branding panel for logo upload, favicon upload, primary/secondary color pickers, and font selection
  • app/components/dam/portals/PortalTileList.vue — lists all content tiles; supports reordering and delete
  • app/components/dam/portals/PortalUpgradePlanSection.vue — plan-gate prompt shown when max_instances is reached
  • app/components/dam/portals/StaticPlanWorkspaceBranding.vue — read-only branding section for plans that don't include branding customization
  • app/components/dam/portals/CNameGuideDialog.vue — step-by-step guide for configuring a custom CNAME for the portal URL
  • app/components/dam/portals/InvitePortalUserDialog.vue — invite dialog for adding external users to a portal
  • app/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, getWorkspaceDetail
  • app/composables/api/usePortalBannerApi.ts — banner CRUD: list, create, update, delete, reorder
  • app/composables/api/usePortalTileApi.ts — tile CRUD: list, create, update, delete, reorder
  • app/composables/api/usePortalUsersApi.ts — user management: list portal users, invite, remove, update roles
  • app/composables/api/usePortalUsersSettingsApi.ts — per-portal user permission settings
  • app/composables/queries/usePortalsListQuery.ts — TanStack reactive query for portal list and workspace branding; exposes invalidatePortalsList
  • app/composables/queries/usePortalBannersQuery.ts — reactive query for banners of a specific portal
  • app/composables/queries/usePortalTilesQuery.ts — reactive query for tiles of a specific portal
  • app/composables/queries/usePortalDetailUsersQuery.ts — reactive query for users of a specific portal
  • app/composables/queries/usePermissionPortalsQuery.ts — reactive query for portals used in collage/asset permission dialogs
  • app/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" button
  • app/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 detail

  • app/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 access
  • app/pages/[workspace_id]/external/request-submitted.vue — confirmation page after access request
  • app/pages/[workspace_id]/external/upload.vue — external contributor upload page
  • app/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) ​

FieldValue
Route/:workspace_id/dam/portals
Layoutcollage-layout (embedsHeader: true)
Middlewareauth-check, check-workspace, can-access-dam-module, check-if-suspended
Primary composableusePortalsList()

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)

DialogWhen shown
PortalLimitDialogUser has reached their portal plan limit (isAtPortalLimit)
CreatePortalDialogUser is within plan limits

Portal Detail ([workspace_id]/dam/portals/[id].vue) ​

FieldValue
Route/:workspace_id/dam/portals/:id
Layoutcollage-layout
Middlewareauth-check, check-workspace, can-access-dam-module, check-if-suspended, check-workspace-access
Primary composableusePortalDetail()

Renders a tab bar whose visible tabs are permission-controlled.

TabComponentGate
CustomizePortalBrandingTabalways visible
UsersDamSettingsListView with scroll-based infinite scrollcanManageUsers
Content BlocksPortalBannerList + PortalTileListcanManageBanners || 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) ​

FieldValue
Route/:workspace_id/dam/add-instance
Layoutlogin-layout
Middlewareauth-check, check-workspace, check-dam-instance
Primary composableuseAddDamInstance()

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) ​

FieldValue
Route (default)/shared-assets/:type
Route (branded)/:brand_name/shared-assets/:type
Layoutfalse (no layout wrapper)
Middlewarenone
Primary composablenone — 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, branding

Portal 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-visibility

Portal 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 setting
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: usePortalTileApi

API 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>