Skip to content

Workspace & Members ​

Overview ​

  1. Workspace is the top-level organizational unit in Collage Admin. Assets, folders, portals, custom fields, and members are all scoped to a single workspace.
  2. Multi-workspace support — a user can belong to multiple workspaces, each with a distinct role. The active workspace is carried in the route as /:workspace_id/.
  3. useWorkspaceSettingsApi wraps all workspace configuration endpoints: detail, logo/favicon, domain, owner change, and subscription retrieval.
  4. useMembersApi handles the full member lifecycle: listing, inviting, editing, revoking, re-activating, and resetting passwords.
  5. Workspace settings pages cover account details, custom fields, members, guest upload management, portal users, announcements, and notification settings.
  6. TanStack Query caches workspace detail reads via useWorkspaceSettingsQuery, preventing redundant API calls across components that share the same data.

Architecture ​

Workspace configuration is split between two composable layers. useWorkspaceSettingsApi talks directly to the backend and is wrapped by useWorkspaceSettings (the page composable for the account settings page), which owns all reactive form state, dialog visibility, and submit orchestration. Member management uses useMembersApi directly from components and pages because each member operation is triggered by a discrete user action rather than a page-level load.

Route access to all workspace settings pages is guarded by can-access-general-settings.ts, which resolves hasGeneralSettingsAccess from the user's workspace role (admin only). The can-access-dam-module.ts middleware guards the nested DAM-specific settings pages (announcements, notification settings).

File Structure ​

TypeScript Files ​

  • app/composables/api/useWorkspaceSettingsApi.ts — Wraps all workspace configuration endpoints: detail, users, settings update, logo/favicon, domain, owner, and subscription
  • app/composables/api/useMembersApi.ts — Wraps the full member lifecycle: list, role modules, permissions, add, invite, edit, delete, revoke, resend invitation, activate, reset password
  • app/composables/core/pages/useWorkspaceSettings.ts — Main page composable for Account Settings; owns form state, logo/favicon upload orchestration, domain update/verify, and subscription data
  • app/composables/queries/useWorkspaceSettingsQuery.ts — TanStack Query cache-aside layer over getWorkspaceDetail; exposes fetchWorkspaceDetail (cache-first) and invalidateWorkspaceDetail
  • app/types/workspace-settings.ts — WorkspaceApiDetail, WorkspaceUser, UpdateWorkspaceSettingsPayload, UpdateDomainPayload, VerifyDomainPayload, UpdateWorkspaceOwnerPayload, SubscriptionData
  • app/types/members.ts — MemberUser, MemberListParams, MemberListResponse, RoleModule, RolePermission, and all member action payload types

Vue Component Files ​

  • app/pages/[workspace_id]/workspace-settings/index.vue — Account Settings page: logo, favicon, custom domain, phone, AI auto-tagging toggle
  • app/pages/[workspace_id]/workspace-settings/user/list.vue — Members page: list, invite, edit, revoke, and activate members
  • app/pages/[workspace_id]/workspace-settings/external-user/list.vue — Guest Upload Manager: manage external users with upload access
  • app/pages/[workspace_id]/workspace-settings/custom-fields/index.vue — Custom Field Manager: create and manage workspace-level custom fields
  • app/pages/[workspace_id]/workspace-settings/portals/users/index.vue — Portal Users: manage brand portal user access
  • app/pages/[workspace_id]/workspace-settings/dam/[instance_id]/announcements/list.vue — Announcements management page
  • app/pages/[workspace_id]/workspace-settings/dam/[instance_id]/notifications/index.vue — Notification Settings page

Middleware Files ​

  • app/middleware/can-access-general-settings.ts — Guards all workspace-settings routes; checks hasGeneralSettingsAccess (admin-only)
  • app/middleware/can-access-dam-module.ts — Guards DAM-scoped settings pages (announcements, notifications)
  • app/middleware/check-workspace-access.ts — Validates workspace membership from user.accessibleWorkspaces

useWorkspaceSettingsApi ​

File: app/composables/api/useWorkspaceSettingsApi.ts

Wraps all workspace configuration backend calls. Every method is wrapped with track() from useApiRequestState to provide shared isLoading and error refs.

Methods ​

javascript
{
  // Fetch workspace detail object (name, logo, favicon, domain, phone, settings)
  getWorkspaceDetail(workspaceId: string | number): Promise<ApiResponse<WorkspaceDetail>>

  // Fetch users scoped to this workspace
  getWorkspaceUsers(workspaceId: string | number): Promise<ApiResponse<WorkspaceUser[]>>

  // Update general workspace settings (name, phone, auto-tagging toggle, etc.)
  updateWorkspaceSettings(payload: UpdateWorkspaceSettingsPayload): Promise<ApiResponse>

  // Upload a new workspace favicon (FormData)
  updateWorkspaceFavicon(formData: FormData): Promise<ApiResponse>

  // Upload a new workspace logo (FormData)
  updateWorkspaceLogo(formData: FormData): Promise<ApiResponse>

  // Submit a custom domain for this workspace
  updateDomain(payload: UpdateDomainPayload): Promise<ApiResponse>

  // Trigger DNS verification for the submitted custom domain
  verifyDomain(payload: VerifyDomainPayload): Promise<ApiResponse>

  // Transfer workspace ownership to another member
  updateWorkspaceOwner(payload: UpdateWorkspaceOwnerPayload): Promise<ApiResponse>

  // Fetch the current subscription plan and feature flags
  getSubscription(): Promise<ApiResponse<SubscriptionData>>
}

Usage ​

vue
<script setup lang="ts">
const {
  getWorkspaceDetail,
  updateWorkspaceSettings,
  updateWorkspaceLogo,
  isLoading,
  error,
} = useWorkspaceSettingsApi()

const { workspaceId } = useWorkspaceId()

const detail = ref(null)

onMounted(async () => {
  const res = await getWorkspaceDetail(workspaceId.value)
  detail.value = res.data
})

async function saveSettings(payload) {
  await updateWorkspaceSettings(payload)
}
</script>

useMembersApi ​

File: app/composables/api/useMembersApi.ts

Wraps the full member lifecycle. Every method is wrapped with track().

Methods ​

javascript
{
  // Paginated member list (supports type, sort, search filters)
  listMembers(params: MemberListParams): Promise<ApiResponse<MemberListResponse>>

  // Get available role-module combinations for the workspace
  getRoleModules(workspaceId: string | number): Promise<ApiResponse<RoleModule[]>>

  // Alias for getRoleModules (backward compat)
  getModuleWithRole(workspaceId: string | number): Promise<ApiResponse<RoleModule[]>>

  // Get role permissions matrix for the workspace
  getRolesPermissions(workspaceId: string | number): Promise<ApiResponse<RolePermission[]>>

  // Fetch a single user's details
  getUserDetails(payload: UserDetailsPayload): Promise<ApiResponse<MemberUser>>

  // Count of admin-role users in the workspace
  getDamAdminCount(workspaceId: string | number): Promise<ApiResponse>

  // Check if an email address is already a member
  checkEmail(payload: CheckEmailPayload): Promise<ApiResponse>

  // Add an existing platform user directly (no invitation email)
  addUser(formData: FormData): Promise<ApiResponse>

  // Send an invitation email to a new user
  inviteUser(formData: FormData): Promise<ApiResponse>

  // Edit an existing member's role or details
  editUser(formData: FormData): Promise<ApiResponse>

  // Remove a member from the workspace
  deleteUser(payload: DeleteUserPayload): Promise<ApiResponse>

  // Revoke a member's active access
  revokeUser(payload: RevokeUserPayload): Promise<ApiResponse>

  // Re-send an invitation email
  resendInvitation(payload: ResendInvitationPayload): Promise<ApiResponse>

  // Re-activate a previously revoked member
  activateUser(payload: ActivateUserPayload): Promise<ApiResponse>

  // Reset a member's password (admin action)
  resetUserPassword(payload: ResetUserPasswordPayload): Promise<ApiResponse>

  // Activate a user and set an initial password in one call
  activateUserWithPassword(payload: ActivateUserWithPasswordPayload): Promise<ApiResponse>
}

API Integration ​

Workspace Endpoints ​

MethodEndpointDescription
GETview-workspace?workspace_id={id}Fetch workspace detail
GETget-workspace-user-data?workspace_id={id}Fetch workspace users
POSTupdate-workspace-settingsUpdate general settings
POSTchange-workspace-faviconUpload new favicon (FormData)
POSTchange-workspace-logoUpload new logo (FormData)
POSTupdate-domainSet custom domain
POSTverify-domainTrigger domain DNS verification
POSTupdate-workspace-ownerTransfer ownership
GETdigital-assets/subscription/getFetch subscription data

Member Endpoints ​

MethodEndpointDescription
POST/user/listPaginated member list
GET/user/role-module?workspace_id={id}Role-module combinations
GET/user/roles-permissions?workspace_id={id}Permission matrix
POSTuser/detailsSingle user details
GETuser/dam-admin-count?workspace_id={id}Admin count
POSTuser/check-emailEmail availability check
POSTuser/add-new-userAdd existing user
POSTuser/invite-userSend invitation email
POSTuser/edit-userEdit member
POST/user/delete-userRemove member
POSTuser/revokeRevoke access
POSTresend-invitationsRe-send invitation
POSTuser/workspace-active-userRe-activate member
POSTuser/user-change-passwordAdmin password reset
POSTuser/active-userActivate with password

Request/Response Examples ​

json
// POST /user/invite-user (FormData fields)
{
  "workspace_id": 42,
  "email": "[email protected]",
  "role_id": 3,
  "module_id": 1
}

// Response
{
  "status": true,
  "message": "Invitation sent successfully.",
  "data": {
    "id": 198,
    "email": "[email protected]",
    "status": "pending"
  }
}
json
// GET view-workspace?workspace_id=42
{
  "data": {
    "id": 42,
    "name": "Acme Brand",
    "logo": "https://cdn.collage.inc/logos/acme.png",
    "favicon": "https://cdn.collage.inc/favicons/acme.png",
    "domain_url": "assets.acme.com",
    "is_domain": 1,
    "is_pointed": true,
    "phone": "+1 555-0100",
    "is_ai_auto_tagging": 1
  }
}

Component Integration ​

Account Settings page (app/pages/[workspace_id]/workspace-settings/index.vue) ​

The page delegates all state and async operations to useWorkspaceSettings(). The template never calls useWorkspaceSettingsApi directly.

vue
<script setup lang="ts">
import type { SuperUser } from '~/components/dam/Dialogs/Org-Settings/WorkspaceOwnerDialog.vue'

definePageMeta({
  layout: 'general-settings-layout',
  middleware: ['auth-check', 'check-workspace', 'can-access-general-settings', 'check-if-suspended'],
})

const {
  workspaceId,
  loading,
  contentLoading,
  uploadingLogo,
  name,
  user_email,
  user_phone,
  logo_preview,
  imageData,
  faviconIcon,
  faviconImageData,
  uploadFileInput,
  logoEditDialog,
  faviconEditDialog,
  fileInputKey,
  faviconKey,
  damBranding,
  error,
  disableSubmitBtn,
  autoTagAllowed,
  globalNotification,
  whiteLabelEnabled,
  showCustomDomainEntry,
  isDomainConnected,
  canCopyCustomDomain,
  customDomain,
  originalCustomDomain,
  domainStatus,
  updateDomainLoading,
  verifyDomainLoading,
  domainError,
  isDomainValid,
  cnameValue,
  workspaceOwnerDialog,
  workspaceSuperUsers,
  submitHandler,
  changeLogo,
  onFileChange,
  reset,
  resetFavicon,
  updateLogoFn,
  updateFaviconFn,
  validatePhoneNum,
  autoTaggingChange,
  updateDomainHandler,
  verifyDomainHandler,
  resetDomain,
  copyCustomDomain,
  openWorkspaceOwnerDialog,
} = useWorkspaceSettings()

const workspaceSuperUsersTyped = computed(() => workspaceSuperUsers.value as unknown as SuperUser[])
</script>

<template>
  <div class="collage-body account-settings">
    <AccountSettingsLeftMenu />
    <v-card class="boxview w-100">
      <v-card-title>
        <h4>Account Settings</h4>
      </v-card-title>
      <v-card-text>
        <!-- Logo upload — v-file-input triggers changeLogo; crop dialog opens after pick -->
        <v-file-input
          :key="fileInputKey"
          v-model="uploadFileInput"
          accept=".jpg, .png, .jpeg"
          :disabled="uploadingLogo"
          @update:model-value="changeLogo"
        />

        <!-- Phone number — validated on input and blur -->
        <v-text-field
          v-model.trim="user_phone"
          @input="validatePhoneNum(user_phone, 'phone')"
          @blur="validatePhoneNum(user_phone, 'phone')"
        />
        <div v-if="error.phone" class="form-control-error">{{ error.phone }}</div>

        <!-- AI auto-tagging — subscription-gated switch bound to damBranding -->
        <v-switch
          v-if="damBranding?.settings"
          v-model="damBranding.settings.auto_tagging"
          inset
          :disabled="!autoTagAllowed"
          @change="autoTaggingChange"
        />

        <v-btn :disabled="loading || disableSubmitBtn" @click="submitHandler">Update</v-btn>
      </v-card-text>

      <!-- Dialogs — crop dialogs open automatically after file pick -->
      <LogoEditDialog
        :dialog="logoEditDialog"
        flag="logo"
        :image-data="imageData ?? undefined"
        :uploading-logo="uploadingLogo"
        :internal-workspace-id="workspaceId"
        @update-logo="updateLogoFn"
        @reset="reset"
      />
      <LogoEditDialog
        :dialog="faviconEditDialog"
        flag="favicon"
        :image-data="faviconImageData ?? undefined"
        :uploading-logo="uploadingLogo"
        :internal-workspace-id="workspaceId"
        @update-logo="updateFaviconFn"
        @reset="resetFavicon"
      />
      <WorkspaceOwnerDialog
        :dialog="workspaceOwnerDialog"
        :workspace-super-users="workspaceSuperUsersTyped"
        :user-email="user_email"
        :internal-workspace-id="workspaceId"
        @close="workspaceOwnerDialog = false"
      />
    </v-card>
  </div>
</template>

Members page (app/pages/[workspace_id]/workspace-settings/user/list.vue) ​

The members page delegates to useMembersPage(). The list is rendered by DamSettingsListView (not a custom MembersTable component).

vue
<script setup lang="ts">
import type { DamMenuItem } from '~/components/dam/DamItemMenu.vue'
import type { MemberUser } from '~/types/members'

definePageMeta({
  layout: 'general-settings-layout',
  middleware: ['auth-check', 'check-workspace', 'can-access-general-settings', 'check-if-suspended'],
})

const {
  contentLoading,
  loading,
  loadMore,
  searchResult,
  selectedUserType,
  userListType,
  userType,
  search_name,
  sort_loader,
  addUsersEnabled,
  addUserDisabledMsg,
  getSubFlag,
  activeUser,
  userAddDialog,
  userDialogHead,
  userDialogFlag,
  resetPassDialog,
  resetPassHeader,
  resetPassSubHeader,
  resetPassFlag,
  deleteUserDialog,
  dialogRoles,
  fetchUsers,
  resetUserList,
  clearSearch,
  changeUserTypeFilter,
  debouncedSearch,
  showAddUserDialog,
  editUser,
  closePassDialog,
  userDialogClose,
  deleteUserDialogClose,
  handleDeleteUser,
  resetPassFn,
  onAddUser,
  onUpdateUser,
  revokeUserFn,
  resendInvitationFn,
} = useMembersPage()
</script>

<template>
  <div class="collage-body account-settings">
    <AccountSettingsLeftMenu />
    <v-card class="boxview w-100">
      <v-card-title>
        <h4>Members</h4>
        <v-select v-model="selectedUserType" :items="userListType" item-title="text" item-value="id"
          @update:model-value="changeUserTypeFilter" />
        <v-text-field v-model="search_name" placeholder="Search members..." @keydown="debouncedSearch" />
        <v-btn :disabled="!addUsersEnabled" @click="showAddUserDialog">Add New User</v-btn>
      </v-card-title>

      <!-- Infinite-scroll list — loadMore guard prevents redundant page fetches -->
      <DamSettingsListView
        :items="memberItems"
        :columns="memberColumns"
        :resolve-menu-items="(item) => buildAdminMenu(item)"
        :loading-more="loadMore"
        @menu-click="handleMenuClick"
        @sort-change="onSortChange"
        @scroll="onMembersScroll"
      />

      <UserDialog :dialog="userAddDialog" :header="userDialogHead" :flag="userDialogFlag"
        :user-prop="activeUser || undefined" :roles="dialogRoles"
        @reload="userDialogFlag === 'add' ? onAddUser() : onUpdateUser(activeUser!)"
        @close="userDialogClose()" />
      <ActivateUserDialog :heading="resetPassHeader" :sub-header="resetPassSubHeader"
        :dialog="resetPassDialog" :flag="resetPassFlag"
        @reset-pass="resetPassFn" @refresh="closePassDialog('submit')" @close="closePassDialog()" />
      <ConfirmationDialog :dialog="deleteUserDialog"
        @confirm="handleDeleteUser()" @cancel="deleteUserDialogClose()" />
    </v-card>
  </div>
</template>

Workflows ​

Account Settings Update Flow ​

1. Admin navigates to Account Settings
   Route: /:workspace_id/workspace-settings
   Middleware: can-access-general-settings.ts
   → Checks hasGeneralSettingsAccess (admin only)
   ↓
2. Page mounts
   Composable: useWorkspaceSettings
   → useWorkspaceSettingsQuery.fetchWorkspaceDetail(workspaceId)
   → Returns cached result if < stale threshold, otherwise fetches GET view-workspace
   → Hydrates form: name, phone, auto-tagging, domain, logo/favicon URLs
   ↓
3. Admin edits and submits
   → updateWorkspaceSettings(payload)
   → POST update-workspace-settings
   → On success: snackbar.success(), invalidate TanStack Query cache
   ↓
4. Logo or favicon upload
   → Admin picks file → file validation (type, size)
   → updateWorkspaceLogo(formData) / updateWorkspaceFavicon(formData)
   → POST change-workspace-logo / change-workspace-favicon
   → Preview updates from API echo response

Member Invitation Flow ​

1. Admin opens Members page
   Route: /:workspace_id/workspace-settings/user/list
   Middleware: can-access-general-settings.ts
   ↓
2. Admin clicks Invite
   → Enter email address
   → useMembersApi.checkEmail({ email }) — validates not already a member
   → Select role from getRoleModules() result
   ↓
3. Submit invitation
   → inviteUser(formData)
   → POST /user/invite-user
   → Backend creates pending record, sends tokenized email
   ↓
4. Recipient clicks invite link
   → Creates account or logs in
   → Member record activated with assigned role
   ↓
5. Admin manages the member
   → editUser() to change role
   → revokeUser() to suspend access
   → activateUser() to re-enable
   → resendInvitation() for pending invites

Custom Domain Setup Flow ​

1. Admin opens Account Settings
   → "Custom Domain" section visible (plan-gated)
   ↓
2. Admin enters desired subdomain (e.g., assets.acme.com)
   → updateDomain({ workspace_id, domain_url: 'assets.acme.com' })
   → POST update-domain
   → Backend stores domain, returns CNAME target
   ↓
3. Admin adds CNAME record in their DNS
   → CNAME: assets.acme.com → portals.collage.inc
   ↓
4. Admin clicks Verify
   → verifyDomain({ workspace_id, domain_url: 'assets.acme.com' })
   → POST verify-domain
   → Backend performs DNS lookup
   ↓
5. Verification result
   → Success: is_pointed set to true, portal reachable at custom domain
   → Failure: error message with DNS guidance shown to admin