Appearance
Workspace & Members ​
Overview ​
- Workspace is the top-level organizational unit in Collage Admin. Assets, folders, portals, custom fields, and members are all scoped to a single workspace.
- 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/. useWorkspaceSettingsApiwraps all workspace configuration endpoints: detail, logo/favicon, domain, owner change, and subscription retrieval.useMembersApihandles the full member lifecycle: listing, inviting, editing, revoking, re-activating, and resetting passwords.- Workspace settings pages cover account details, custom fields, members, guest upload management, portal users, announcements, and notification settings.
- 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 subscriptionapp/composables/api/useMembersApi.ts— Wraps the full member lifecycle: list, role modules, permissions, add, invite, edit, delete, revoke, resend invitation, activate, reset passwordapp/composables/core/pages/useWorkspaceSettings.ts— Main page composable for Account Settings; owns form state, logo/favicon upload orchestration, domain update/verify, and subscription dataapp/composables/queries/useWorkspaceSettingsQuery.ts— TanStack Query cache-aside layer overgetWorkspaceDetail; exposesfetchWorkspaceDetail(cache-first) andinvalidateWorkspaceDetailapp/types/workspace-settings.ts—WorkspaceApiDetail,WorkspaceUser,UpdateWorkspaceSettingsPayload,UpdateDomainPayload,VerifyDomainPayload,UpdateWorkspaceOwnerPayload,SubscriptionDataapp/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 toggleapp/pages/[workspace_id]/workspace-settings/user/list.vue— Members page: list, invite, edit, revoke, and activate membersapp/pages/[workspace_id]/workspace-settings/external-user/list.vue— Guest Upload Manager: manage external users with upload accessapp/pages/[workspace_id]/workspace-settings/custom-fields/index.vue— Custom Field Manager: create and manage workspace-level custom fieldsapp/pages/[workspace_id]/workspace-settings/portals/users/index.vue— Portal Users: manage brand portal user accessapp/pages/[workspace_id]/workspace-settings/dam/[instance_id]/announcements/list.vue— Announcements management pageapp/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; checkshasGeneralSettingsAccess(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 fromuser.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 ​
| Method | Endpoint | Description |
|---|---|---|
| GET | view-workspace?workspace_id={id} | Fetch workspace detail |
| GET | get-workspace-user-data?workspace_id={id} | Fetch workspace users |
| POST | update-workspace-settings | Update general settings |
| POST | change-workspace-favicon | Upload new favicon (FormData) |
| POST | change-workspace-logo | Upload new logo (FormData) |
| POST | update-domain | Set custom domain |
| POST | verify-domain | Trigger domain DNS verification |
| POST | update-workspace-owner | Transfer ownership |
| GET | digital-assets/subscription/get | Fetch subscription data |
Member Endpoints ​
| Method | Endpoint | Description |
|---|---|---|
| POST | /user/list | Paginated member list |
| GET | /user/role-module?workspace_id={id} | Role-module combinations |
| GET | /user/roles-permissions?workspace_id={id} | Permission matrix |
| POST | user/details | Single user details |
| GET | user/dam-admin-count?workspace_id={id} | Admin count |
| POST | user/check-email | Email availability check |
| POST | user/add-new-user | Add existing user |
| POST | user/invite-user | Send invitation email |
| POST | user/edit-user | Edit member |
| POST | /user/delete-user | Remove member |
| POST | user/revoke | Revoke access |
| POST | resend-invitations | Re-send invitation |
| POST | user/workspace-active-user | Re-activate member |
| POST | user/user-change-password | Admin password reset |
| POST | user/active-user | Activate 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 responseMember 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 invitesCustom 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 adminRelated Documentation ​
- Permissions — Role resolution and access gates
- Branding — Portal branding and white-label setup
- External Access — Guest upload manager
- Notifications — Workspace notification settings
- Subscription — Plan gates for custom domains and features