Appearance
Subscription & Plans ​
Overview ​
- Feature-flag model: The backend returns a
featuresmap inside the subscription response. Each key is a feature name; the value is aSubscriptionFeatureobject with anenable: booleanfield and an optionalmessagestring shown to users when the feature is locked. - Single shared query:
useSubscriptionQuerywraps TanStack Query arounduseWorkspaceSettingsApi.getSubscription. All pages that need plan data use this composable so the response is cached once per workspace instead of fetched independently by each page. - Workspace-scoped: The query key includes the workspace ID, so switching workspaces invalidates and re-fetches the subscription automatically.
- Component-level gating: Components read
subscriptionQuery.data.featuresdirectly and derive computed booleans for each gate. There is no separate permissions layer for subscription checks. - Upgrade prompts: When a gated feature is disabled, components render a dedicated upgrade UI section (
UpgradePlanSection,StaticPlanWorkspaceBranding,PortalUpgradePlanSection,StaticPlanNotificationSettings) or an inline tooltip. The portal creation limit is handled specifically byPortalLimitDialog. - Real-time plan update: After a subscription event arrives on the Pusher channel
user.{id}.getUserSubscriptionChannel, the notification composable callsfetchUser()and refreshes workspace data. TanStack Query cache is invalidated to ensure subscription data re-fetches.
Architecture ​
Subscription data flows from a single backend endpoint (digital-assets/subscription/get) through useWorkspaceSettingsApi.getSubscription, cached by useSubscriptionQuery via TanStack Query. The cache key is queryKeys.settings.subscription(workspaceId) and is enabled only when a workspace ID is present. Components consume subscriptionQuery.data directly — typically destructuring features from the response — and derive computed booleans to control visibility or disabled states of gated UI elements. Upgrade prompts are standalone components placed inline at the point of enforcement. The PortalSubscription type (on the portal object) carries max_instances and max_storage for per-portal limits, while the workspace-level subscription carries the feature flags.
File Structure ​
TypeScript Files ​
app/composables/queries/useSubscriptionQuery.ts— TanStack Query wrapper; the canonical way to read subscription data in any componentapp/composables/api/useWorkspaceSettingsApi.ts— API composable containinggetSubscription()which callsdigital-assets/subscription/getapp/types/workspace-settings.ts— definesSubscriptionDatainterfaceapp/types/portal.ts— definesSubscriptionFeature,SubscriptionFeatures, andPortalSubscriptioninterfacesapp/constants/queryKeys.ts— definesqueryKeys.settings.subscription(workspaceId)cache key
Vue Component Files ​
app/components/dialogs/PortalLimitDialog.vue— shown when user tries to create a portal beyond the plan'smax_instanceslimitapp/components/shared/UpgradePlanSection.vue— generic upgrade CTA section for portal-level gatingapp/components/dam/portals/PortalUpgradePlanSection.vue— portal-specific upgrade prompt rendered inside portal settingsapp/components/dam/portals/StaticPlanWorkspaceBranding.vue— upgrade section shown in workspace branding settings when branding features are lockedapp/components/dam/StaticPlanNotificationSettings.vue— upgrade section for locked notification settings
useSubscriptionQuery Composable ​
File: app/composables/queries/useSubscriptionQuery.ts
Features ​
- Reads workspace ID from route params with a fallback to
useWorkspaceId() - Computed
queryKey— reactively updates if workspace ID changes - Enabled guard — query is disabled when workspace ID is absent
- Returns the raw
SubscriptionDataobject as returned by the backend
Return Value ​
typescript
{
subscriptionQuery: UseQueryReturnType<SubscriptionData, Error>
}Usage ​
typescript
const { subscriptionQuery } = useSubscriptionQuery()
// Access feature flags
const features = computed(() => subscriptionQuery.data.value?.features ?? {})
// Derive a per-feature gate
const brandingEnabled = computed(
() => features.value.dam_branding?.enable === true
)
// Show an upgrade message from the backend
const brandingMessage = computed(
() => features.value.dam_branding?.message ?? ''
)Type Reference ​
SubscriptionData ​
typescript
// app/types/workspace-settings.ts
export interface SubscriptionData {
features?: Record<string, SubscriptionFeature>
[key: string]: unknown
}SubscriptionFeature ​
typescript
// app/types/portal.ts
export interface SubscriptionFeature {
enable?: boolean // true = feature is active on this plan
message?: string // human-readable upgrade prompt text
[key: string]: unknown
}SubscriptionFeatures (named feature keys) ​
typescript
// app/types/portal.ts
export interface SubscriptionFeatures {
custom_brand_url?: SubscriptionFeature // white-label / custom domain
dam_branding?: SubscriptionFeature // portal branding (colours, logo, favicon)
[key: string]: SubscriptionFeature | undefined
}PortalSubscription (per-portal limits) ​
typescript
// app/types/portal.ts
export interface PortalSubscription {
max_instances?: number // portal count limit
max_storage?: number | string // storage limit in GB
features?: SubscriptionFeatures // per-portal feature flags
[key: string]: unknown
}useWorkspaceSettingsApi — getSubscription ​
File: app/composables/api/useWorkspaceSettingsApi.ts
typescript
const getSubscription = async (): Promise<ApiResponse<SubscriptionData>> => {
return await $api.get<SubscriptionData>('digital-assets/subscription/get')
}Use useSubscriptionQuery rather than calling getSubscription directly. The query composable ensures a single cached fetch is shared across all consumers.
Plan Gating Pattern ​
Components derive computed booleans from the feature flags and use them to conditionally render upgrade prompts or disable controls:
vue
<template>
<div>
<template v-if="brandingEnabled">
<!-- Branding controls -->
<BrandingColorPicker />
</template>
<template v-else>
<StaticPlanWorkspaceBranding :message="brandingMessage" />
</template>
</div>
</template>
<script setup lang="ts">
const { subscriptionQuery } = useSubscriptionQuery()
const features = computed(() => subscriptionQuery.data.value?.features ?? {})
const brandingEnabled = computed(
() => features.value.dam_branding?.enable === true
)
const brandingMessage = computed(
() => features.value.dam_branding?.message ?? 'Upgrade to enable workspace branding.'
)
</script>Avoid duplicating this pattern. If the same feature gate appears in more than one component, extract the computed to a shared composable.
Workflows ​
Subscription Data Load ​
1. Component mounts in a workspace context
↓
2. useSubscriptionQuery() called
queryKey = queryKeys.settings.subscription(workspaceId)
enabled = true (workspaceId is present)
↓
3. TanStack Query checks cache
Cache hit → return cached SubscriptionData immediately
Cache miss → call useWorkspaceSettingsApi.getSubscription()
↓
4. API call
GET digital-assets/subscription/get
Authorization: Bearer {auth_token}
↓
5. Response stored in TanStack Query cache
subscriptionQuery.data available to all consumers on this workspace
↓
6. Components derive computed booleans from features map
Render feature UI or upgrade prompt accordinglyPortal Creation Limit Enforcement ​
1. User clicks "Create Portal"
Component: CreatePortalDialog
↓
2. Dialog reads max_instances from PortalSubscription
Also reads current portal count from portals list query
↓
3. Count check
current >= max_instances → show PortalLimitDialog
current < max_instances → proceed with creation form
↓
4. PortalLimitDialog shown
Displays plan limit and current usage
"Upgrade" button links to billing / plan upgrade page
↓
5. User upgrades plan
Backend webhook updates subscription
useSubscriptionQuery cache is invalidated
↓
6. Cache re-fetches
subscriptionQuery.data updated with new limits
CreatePortalDialog now permits creationReal-time Subscription Update ​
1. User completes payment on billing page
↓
2. Backend processes webhook
Pusher event pushed to:
user.{id}.getUserSubscriptionChannel
Event: .GetUserSubscriptionEvent
Payload: { plan_assign: true }
↓
3. useDamNotification listener fires
Calls fetchUser() to refresh auth user object
Calls viewWorkspace() to get updated workspace data
Calls updateSubscriptionEventSent() to acknowledge event
↓
4. TanStack Query subscription cache invalidated
useSubscriptionQuery re-fetches from digital-assets/subscription/get
↓
5. New plan limits visible immediately
Upgrade prompts hide, gated features unlockAPI Integration ​
Endpoints ​
| Method | Endpoint | Description |
|---|---|---|
| GET | digital-assets/subscription/get | Current workspace subscription data and feature flags |
Response Shape ​
json
{
"data": {
"features": {
"dam_branding": {
"enable": true,
"message": null
},
"custom_brand_url": {
"enable": false,
"message": "Upgrade to Pro to enable custom domain support."
},
"system_notifications": {
"enable": true,
"message": null
}
}
}
}Component Integration ​
Gating a Feature with Upgrade Prompt ​
Feature flags are read from subscriptionQuery.data.value?.features. All upgrade prompt components (UpgradePlanSection, PortalUpgradePlanSection, StaticPlanWorkspaceBranding, StaticPlanNotificationSettings) accept no props — they render hardcoded static UI and a "Contact Support" mailto link.
vue
<template>
<div>
<template v-if="usersEnabled">
<!-- user management controls -->
</template>
<UpgradePlanSection v-else />
</div>
</template>
<script setup lang="ts">
const { subscriptionQuery } = useSubscriptionQuery()
const usersEnabled = computed(
() => !!subscriptionQuery.data.value?.features?.users?.enable
)
</script>Checking a Limit Before an Action ​
User-count limits live inside the feature flag object, not at the top level of the subscription response. The actual implementation from usePortalUsersPageSettings.ts:
typescript
const { subscriptionQuery } = useSubscriptionQuery()
const usersEnabled = computed(
() => !!subscriptionQuery.data.value?.features?.users?.enable
)
const canAddMoreUsers = computed(() => {
if (!usersEnabled.value) return false
const maxAllowedUsers = subscriptionQuery.data.value?.features?.users?.users
if ((maxAllowedUsers + '').trim().toLowerCase() === 'unlimited') return true
if (totalInstanceUsers.value >= parseInt(String(maxAllowedUsers))) return false
return true
})totalInstanceUsers comes from the portal users list query. When canAddMoreUsers is false, the "Add New User" button is disabled with a tooltip explaining the plan limit.
Upgrade Prompt Components ​
These components are placed at the point of enforcement rather than behind a central upgrade modal:
StaticPlanWorkspaceBranding ​
File: app/components/dam/portals/StaticPlanWorkspaceBranding.vue
Rendered inside workspace branding settings when dam_branding.enable === false. Replaces the color picker, logo upload, and favicon controls with a locked panel and an upgrade call to action.
StaticPlanNotificationSettings ​
File: app/components/dam/StaticPlanNotificationSettings.vue
Rendered in notification settings when system notifications are locked on the current plan. Shows the upgrade message from features.system_notifications.message.
PortalUpgradePlanSection ​
File: app/components/dam/portals/PortalUpgradePlanSection.vue
Rendered inside a portal's settings page when a portal-level feature is locked. Includes the feature name and the upgrade CTA.
UpgradePlanSection ​
File: app/components/shared/UpgradePlanSection.vue
Generic upgrade section component. Accepts a message prop from the backend feature flag. Used wherever a generic "upgrade your plan" prompt is needed without portal-specific context.
PortalLimitDialog ​
File: app/components/dialogs/PortalLimitDialog.vue
Modal shown when a user tries to create a new portal after reaching max_instances. It displays the current portal count, the plan limit, and a link to upgrade. It does not block navigation — the user can dismiss it and return to the portal list.
canAddMoreUsers Pattern ​
The portal users page derives a boolean from subscription data to gate the "Add New User" button. From app/composables/core/pages/usePortalUsersPageSettings.ts:
typescript
const { subscriptionQuery } = useSubscriptionQuery()
const usersEnabled = computed(
() => !!subscriptionQuery.data.value?.features?.users?.enable
)
const canAddMoreUsers = computed(() => {
if (!usersEnabled.value) return false
const maxAllowedUsers = subscriptionQuery.data.value?.features?.users?.users
if ((maxAllowedUsers + '').trim().toLowerCase() === 'unlimited') return true
if (totalInstanceUsers.value >= parseInt(String(maxAllowedUsers))) return false
return true
})The button disables and shows a tooltip: "Please upgrade your subscription plan to add more users". This is an inline enforcement pattern — no modal is shown, the CTA is the tooltip text itself.
Feature Flag Key Reference ​
The features map uses string keys. Currently documented keys in SubscriptionFeatures:
| Key | Purpose |
|---|---|
custom_brand_url | Custom domain / white-label CNAME |
dam_branding | Portal branding (theme, logo, favicon) |
system_notifications | In-app system notification bell |
Additional keys exist (e.g. for auto-tagging, insights, file conversion) and are accessed via the [key: string]: SubscriptionFeature | undefined index signature.
Query Key Structure ​
typescript
// From constants/queryKeys.ts
queryKeys.settings.subscription(workspaceId: string)
// Returns a stable array used as the TanStack Query cache key.
// Invalidate this key to force a subscription re-fetch:
queryClient.invalidateQueries({
queryKey: queryKeys.settings.subscription(workspaceId),
})