Appearance
Activity Log ​
Overview ​
- Per-user activity dialog:
ActivityLogsDialogis a Vuetify dialog component that shows an infinite-scroll list of activity log entries for a specific portal user. It is not a standalone page — it is launched from the portal users management list. - Triggered from context menu: The portal users page (
workspace-settings/portals/users) exposes an "Activity Log" action in each user's row menu. Selecting it opens the dialog with the chosen user pre-loaded. - Direct API call: The component calls the backend endpoint
digital-assets/instance/user-activitydirectly via$api— there is no dedicateduseAuditLogApicomposable. - Infinite scroll pagination: Entries are fetched one page at a time. Scrolling to 85% of the container depth triggers the next page load without a manual "Load more" button.
- Redirect links: Log entries that carry an
activity_urlfield display an external-link icon. Clicking it opens the linked resource in a new tab. - Soft reset on re-open: Each time the dialog opens, it resets page, lastPage, and the entries array before fetching fresh data.
Architecture ​
The activity log feature is a single self-contained dialog component. State — pagination cursors, the entries array, loading flags — is held locally with ref; no Pinia store is involved. The component uses the global $api client (provided by the fetch plugin) and reads route.params.workspace_id for the workspace context. The consuming page passes a user prop containing the portal user record; the dialog extracts user.id to scope the API call. Skeleton loaders cover both the initial load and incremental page fetches so the UI never shows a hard blank state.
File Structure ​
Vue Component Files ​
app/components/dam/Dialogs/Org-Settings/ActivityLogsDialog.vue— dialog that renders the paginated activity log for a single portal user
Page Files ​
app/pages/[workspace_id]/workspace-settings/portals/users/index.vue— portal users list page; triggersActivityLogsDialogvia the per-row context menu "Activity Log" action
Page Details ​
Portal Users (workspace-settings/portals/users/index.vue) ​
| Field | Value |
|---|---|
| Route | /:workspace_id/workspace-settings/portals/users |
| Layout | general-settings-layout |
| Middleware | auth-check, check-workspace, can-access-dam-settings, check-if-suspended |
| Primary composable | usePortalUsersPageSettings() |
Three status tabs — Active Users, Pending Users, Deactive Users — controlled by filterSelectModel. Searchable by name or email, with infinite scroll and sort by name and email. Subscription-gated "Add New User" button. Last Login and Last Activity columns appear only on the Active Users tab.
List columns: Name, Email, Portals, Last Login (active tab only), Last Activity (active tab only)
Row menu by tab
| Tab | Menu items |
|---|---|
| Active | Edit, Activity Log, Reset Password, Deactivate User |
| Pending | Resend Invitation, Revoke Access, Activate User, Delete Invitation Record |
| Deactive | Activate User |
Dialogs: ConfirmationDialog, ActivateUserDialog (reset password / activate), UserDialog (add/edit), ActivityLogsDialog
ActivityLogsDialog calls GET digital-assets/instance/user-activity directly, shows a two-column table (Date/Time, Logs), and pages at 85% scroll with silent error handling.
ActivityLogsDialog Component ​
File: app/components/dam/Dialogs/Org-Settings/ActivityLogsDialog.vue
Features ​
- Opens as a persistent
v-dialogat 850 px max-width - Skeleton loaders during initial fetch (table-header + 9 row skeletons)
- Empty state (
EmptyStateIcon+ "No activity found!" heading) when no entries exist - Two-column table: Date / Time and Logs
- Tooltip on log text for overflow cases
- External link button per row when
activity_urlis present - Skeleton rows appended at the bottom during incremental page loads
- Resets all state each time
dialogprop flips totrue
Props ​
typescript
{
dialog: boolean // controls v-model open/close state
user: Record<string, unknown> // portal user record; must contain user.id
}Events ​
typescript
{
close: [] // emitted when the user dismisses the dialog via the close button
}Internal State ​
typescript
{
modalDialog: Ref<boolean> // mirrors dialog prop, drives v-dialog v-model
activities: Ref<Activity[]> // accumulated log entries
isLoading: Ref<boolean> // true during initial page 1 fetch
currentPage: Ref<number> // current page cursor (starts at 0)
lastPage: Ref<number | null> // last page from API response
loadMore: Ref<boolean> // true during incremental fetches (scroll)
listLogsContainer: Ref<HTMLElement | null> // scroll container ref for 85% threshold
}Key Methods ​
typescript
// Reset pagination and entries before each open
reset(): void
// Dismisses dialog and emits close
closeDialog(): void
// Format a UTC date string as "Jan 1, 2025 3:45 PM"
formatDate(dateStr: string): string
// Fetch one page of entries; flag = 'default' shows full skeleton,
// flag = 'scroll' appends skeleton rows at the bottom
handleFetchActivities(flag?: 'default' | 'scroll'): Promise<void>
// Fires handleFetchActivities('scroll') when container is scrolled past 85%
handleScroll(): Promise<void>Usage ​
vue
<template>
<ActivityLogsDialog
:dialog="activityLogOpen"
:user="selectedUser"
@close="activityLogOpen = false"
/>
</template>
<script setup lang="ts">
const activityLogOpen = ref(false)
const selectedUser = ref<Record<string, unknown>>({})
function openActivityLog(user: Record<string, unknown>) {
selectedUser.value = user
activityLogOpen.value = true
}
</script>Workflows ​
Opening the Activity Log for a Portal User ​
1. Admin opens workspace-settings/portals/users
Page: app/pages/[workspace_id]/workspace-settings/portals/users/index.vue
↓
2. Admin clicks the row action menu for a portal user
Menu item: { key: 'activity-log', label: 'Activity Log' }
↓
3. Page sets selectedUser.value = user and activity_log_dialog.value = true
↓
4. ActivityLogsDialog receives dialog=true via prop
Watcher fires → calls reset() → calls handleFetchActivities('default')
↓
5. Initial load
isLoading = true
currentPage = 1
API: GET digital-assets/instance/user-activity
Params: { url_workspace_id, workspace_id, page: 1, user_id }
↓
6. Response parsed
lastPage set from response.data.last_page
activities populated from response.data.data
isLoading = false
↓
7. Table renders
Column 1: formatDate(activity.created_at)
Column 2: activity.log_details with tooltip
Optional: redirect icon button if activity.activity_url is set
↓
8. User scrolls the table body
handleScroll fires on each scroll event
scrollPercent = (scrollTop / (scrollHeight - clientHeight)) * 100
↓
9. If scrollPercent >= 85 and more pages exist
handleFetchActivities('scroll') called
loadMore = true → bottom skeleton rows appear
Next page fetched and pushed to activities array
loadMore = false → skeleton rows removed
↓
10. User closes dialog
closeDialog() → modalDialog = false → emit('close') → reset()API Integration ​
Endpoints ​
| Method | Endpoint | Description |
|---|---|---|
| GET | digital-assets/instance/user-activity | Paginated activity log for a single user |
Request Parameters ​
json
{
"url_workspace_id": "abc123",
"workspace_id": "abc123",
"page": 1,
"user_id": 42
}Response Shape ​
json
{
"data": {
"current_page": 1,
"last_page": 4,
"data": [
{
"id": 1001,
"created_at": "2025-07-15T14:23:00Z",
"log_details": "Downloaded asset product-hero.png",
"activity_url": "/workspace/abc123/dam/files/555"
},
{
"id": 1000,
"created_at": "2025-07-14T09:10:00Z",
"log_details": "Viewed portal Brand Assets",
"activity_url": null
}
]
}
}Component Integration ​
ActivityLogsDialog is embedded in app/pages/[workspace_id]/workspace-settings/portals/users/index.vue. All page state — dialog flags, selected user, user list — comes from usePortalUsersPageSettings(). The page does not use v-data-table; it uses DamSettingsListView with a resolve-menu-items callback and a @menu-click handler.
The dialog state variable is activity_log_dialog (snake_case, matching the composable). Menu items are built per-row by buildUserMenu(), which returns different actions depending on the active filter tab (active-users, pending-users, or deactive-users). The activity-log action only appears in the active-users tab.
vue
<!-- app/pages/[workspace_id]/workspace-settings/portals/users/index.vue -->
<template>
<DamSettingsListView
:items="portalUserItems"
:resolve-menu-items="(item) => buildUserMenu(item)"
@menu-click="handleMenuClick"
@scroll="onPortalUsersScroll"
/>
<ActivityLogsDialog
:dialog="activity_log_dialog"
:user="selectedUser || {}"
@close="activity_log_dialog = false"
/>
</template>
<script setup lang="ts">
import type { DamMenuItem } from '~/components/dam/DamItemMenu.vue'
import type { FolderAssetItem } from '~/types/dam-list'
import type { PortalUserRecord } from '~/types/portal'
const {
activity_log_dialog,
selectedUser,
searchResult,
getSubFlag,
} = usePortalUsersPageSettings()
function buildUserMenu(item: FolderAssetItem): DamMenuItem[] {
const subFlag = getSubFlag.value
if (subFlag === 'active-users') {
return [
{ key: 'edit', icon: 'editIcon', label: 'Edit' },
{ key: 'activity-log', icon: 'eyeIcon', label: 'Activity Log' },
{ key: 'reset-password', icon: 'resetPassIcon', label: 'Reset Password' },
{ key: 'deactivate-user', icon: 'deactivateIcon', label: 'Deactivate User' },
]
}
if (subFlag === 'pending-users') {
return [
{ key: 'resend-invitation', icon: 'resendInviteIcon', label: 'Resend Invitation' },
{ key: 'revoke-access', icon: 'revokeMailIcon', label: 'Revoke Access' },
{ key: 'activate-user', icon: 'checkCircleIcon', label: 'Activate User' },
{ key: 'delete-invitation-record', icon: 'deleteIcon', label: 'Delete Invitation Record' },
]
}
if (subFlag === 'deactive-users') {
return [{ key: 'activate-user', icon: 'checkCircleIcon', label: 'Activate User' }]
}
return []
}
function findPortalUserById(id: FolderAssetItem['id']): PortalUserRecord | undefined {
return searchResult.value.find((u) => String(u.id) === String(id))
}
function handleMenuClick(item: FolderAssetItem, menuItem: DamMenuItem) {
const user = findPortalUserById(item.id)
if (!user) return
switch (menuItem.key) {
case 'activity-log':
selectedUser.value = user
activity_log_dialog.value = true
break
// other cases handled similarly
}
}
</script>Portal Users Page Context ​
File: app/pages/[workspace_id]/workspace-settings/portals/users/index.vue
Route: /:workspace_id/workspace-settings/portals/users
The portal users page is the only page that currently embeds ActivityLogsDialog. It manages portal user accounts — inviting, editing, deactivating, resetting passwords, and viewing activity. The page also checks the workspace subscription before allowing new user additions.
Key State ​
typescript
const activityLogOpen = ref(false) // controls ActivityLogsDialog visibility
const selectedUser = ref<Record<string, unknown> | null>(null) // user passed to dialogRow Action Menu Items ​
typescript
[
{ key: 'edit', icon: 'editIcon', label: 'Edit' },
{ key: 'activity-log', icon: 'eyeIcon', label: 'Activity Log' },
{ key: 'reset-password', icon: 'resetPassIcon', label: 'Reset Password' },
{ key: 'deactivate-user', icon: 'deactivateIcon', label: 'Deactivate User' },
]Subscription-Gated Add User Button ​
The "Add New User" button is disabled with a tooltip when the workspace has reached its portal user limit:
vue
<v-tooltip :disabled="canAddMoreUsers" bottom>
<template #activator="{ props: tooltipProps }">
<v-btn
v-bind="tooltipProps"
:disabled="!canAddMoreUsers"
@click="addNewUser('open')"
>
Add New User
</v-btn>
</template>
<span>Please upgrade your subscription plan to add more users</span>
</v-tooltip>Portal Users Filtering ​
The page provides two controls for browsing users:
- Status filter (
v-select): dropdown filter (e.g. active, pending, deactivated) bound tofilterSelectModel - Search field (
v-text-field): filters by name or email, bound tosearchTerm
Both controls are disabled while a fetch is in progress (fetchingUser || loadMore).
Error Handling ​
ActivityLogsDialog catches all API and scroll errors silently — neither surfaces a snackbar nor sets an error state visible to the user. The component simply stops loading. If the initial fetch fails, activities remains an empty array and the empty state ("No activity found!") is shown. If a scroll-triggered fetch fails, loadMore is reset to false and the skeleton rows disappear. Pages that integrate the dialog should not attempt to detect or handle errors from within it.
typescript
// Both catch blocks in the component are intentionally empty:
try {
// API call
} catch {
// silent fail
}Activity Interface ​
The Activity interface is defined locally in the component (not in app/types/):
typescript
interface Activity {
id: number
created_at: string // ISO 8601 UTC timestamp
log_details: string // human-readable log message
activity_url?: string | null // optional deep-link into the DAM
}formatDate converts created_at to the user's locale:
typescript
// Input: "2025-07-15T14:23:00Z"
// Output: "Jul 15, 2025 2:23 PM" (en-US locale)
formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
+ ' ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
}