From 42ad8fbcb238c6f9f2e1d281fc73694cb8d6c0e5 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Mon, 4 May 2026 13:52:09 +0100 Subject: [PATCH 1/4] feat: implement access tab with dummy data --- .../src/pages/hosting/manage/Access.vue | 7 + .../src/pages/hosting/manage/index.js | 3 +- apps/app-frontend/src/routes.js | 8 + .../src/pages/hosting/manage/[id]/access.vue | 13 + .../illustrations/intercom_bubble_icon.png | Bin 0 -> 32340 bytes packages/assets/generated-icons.ts | 4 +- packages/assets/index.ts | 2 + packages/ui/src/components/base/Combobox.vue | 7 +- .../src/components/servers/ServerListing.vue | 26 + .../components/servers/access/AccessTable.vue | 421 ++++++++++++++++ .../servers/access/AuditLogTable.vue | 470 ++++++++++++++++++ .../servers/access/GrantAccessModal.vue | 275 ++++++++++ .../ui/src/components/servers/access/index.ts | 4 + .../ui/src/components/servers/access/types.ts | 62 +++ packages/ui/src/components/servers/index.ts | 1 + .../servers/marketing/MedalServerListing.vue | 26 + .../layouts/wrapped/hosting/manage/access.vue | 439 ++++++++++++++++ .../layouts/wrapped/hosting/manage/index.vue | 189 +++++-- .../layouts/wrapped/hosting/manage/root.vue | 7 + packages/ui/src/layouts/wrapped/index.ts | 1 + packages/ui/src/locales/en-US/index.json | 276 ++++++++++ .../stories/servers/AccessTable.stories.ts | 135 +++++ .../stories/servers/AuditLogTable.stories.ts | 145 ++++++ .../servers/GrantAccessModal.stories.ts | 44 ++ .../stories/servers/ServerListing.stories.ts | 10 + 25 files changed, 2531 insertions(+), 44 deletions(-) create mode 100644 apps/app-frontend/src/pages/hosting/manage/Access.vue create mode 100644 apps/frontend/src/pages/hosting/manage/[id]/access.vue create mode 100644 packages/assets/external/illustrations/intercom_bubble_icon.png create mode 100644 packages/ui/src/components/servers/access/AccessTable.vue create mode 100644 packages/ui/src/components/servers/access/AuditLogTable.vue create mode 100644 packages/ui/src/components/servers/access/GrantAccessModal.vue create mode 100644 packages/ui/src/components/servers/access/index.ts create mode 100644 packages/ui/src/components/servers/access/types.ts create mode 100644 packages/ui/src/layouts/wrapped/hosting/manage/access.vue create mode 100644 packages/ui/src/stories/servers/AccessTable.stories.ts create mode 100644 packages/ui/src/stories/servers/AuditLogTable.stories.ts create mode 100644 packages/ui/src/stories/servers/GrantAccessModal.stories.ts diff --git a/apps/app-frontend/src/pages/hosting/manage/Access.vue b/apps/app-frontend/src/pages/hosting/manage/Access.vue new file mode 100644 index 0000000000..2e57c11cbd --- /dev/null +++ b/apps/app-frontend/src/pages/hosting/manage/Access.vue @@ -0,0 +1,7 @@ + + + diff --git a/apps/app-frontend/src/pages/hosting/manage/index.js b/apps/app-frontend/src/pages/hosting/manage/index.js index 50052e3f9e..0c10b07133 100644 --- a/apps/app-frontend/src/pages/hosting/manage/index.js +++ b/apps/app-frontend/src/pages/hosting/manage/index.js @@ -1,7 +1,8 @@ +import Access from './Access.vue' import Backups from './Backups.vue' import Content from './Content.vue' import Files from './Files.vue' import Index from './Index.vue' import Overview from './Overview.vue' -export { Backups, Content, Files, Index, Overview } +export { Access, Backups, Content, Files, Index, Overview } diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js index c12306b5ad..2057d67e17 100644 --- a/apps/app-frontend/src/routes.js +++ b/apps/app-frontend/src/routes.js @@ -73,6 +73,14 @@ export default new createRouter({ breadcrumb: [{ name: '?Server' }], }, }, + { + path: 'access', + name: 'ServerManageAccess', + component: Hosting.Access, + meta: { + breadcrumb: [{ name: '?Server' }], + }, + }, ], }, { diff --git a/apps/frontend/src/pages/hosting/manage/[id]/access.vue b/apps/frontend/src/pages/hosting/manage/[id]/access.vue new file mode 100644 index 0000000000..52f6f92df5 --- /dev/null +++ b/apps/frontend/src/pages/hosting/manage/[id]/access.vue @@ -0,0 +1,13 @@ + + + diff --git a/packages/assets/external/illustrations/intercom_bubble_icon.png b/packages/assets/external/illustrations/intercom_bubble_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6585b9b09eb8b4e92c8a1b9e47bb460e9793c0cd GIT binary patch literal 32340 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
doyakDdP{{n9`C@gcJP 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 - import _AffiliateIcon from './icons/affiliate.svg?component' import _AlignLeftIcon from './icons/align-left.svg?component' import _ArchiveIcon from './icons/archive.svg?component' @@ -395,6 +393,8 @@ import _XCircleIcon from './icons/x-circle.svg?component' import _ZoomInIcon from './icons/zoom-in.svg?component' import _ZoomOutIcon from './icons/zoom-out.svg?component' +export type IconComponent = FunctionalComponent + export const AffiliateIcon = _AffiliateIcon export const AlignLeftIcon = _AlignLeftIcon export const ArchiveIcon = _ArchiveIcon diff --git a/packages/assets/index.ts b/packages/assets/index.ts index 4fb4028886..7a8ed170cd 100644 --- a/packages/assets/index.ts +++ b/packages/assets/index.ts @@ -41,6 +41,7 @@ import _DiscordIcon from './external/discord.svg?component' import _FacebookIcon from './external/facebook.svg?component' import _FlathubIcon from './external/flathub.svg?component' import _GithubIcon from './external/github.svg?component' +import _IntercomBubbleIcon from './external/illustrations/intercom_bubble_icon.png?url' import _MinecraftServerIcon from './external/illustrations/minecraft_server_icon.png?url' import _InstagramIcon from './external/instagram.svg?component' import _KoFiIcon from './external/kofi.svg?component' @@ -132,6 +133,7 @@ export const VenmoIcon = _VenmoIcon export const PolygonIcon = _PolygonIcon export const USDCColorIcon = _USDCColorIcon export const VisaIcon = _VisaIcon +export const IntercomBubbleIcon = _IntercomBubbleIcon export const MinecraftServerIcon = _MinecraftServerIcon export * from './generated-icons' diff --git a/packages/ui/src/components/base/Combobox.vue b/packages/ui/src/components/base/Combobox.vue index bf340ffd58..52746bd8e2 100644 --- a/packages/ui/src/components/base/Combobox.vue +++ b/packages/ui/src/components/base/Combobox.vue @@ -95,6 +95,7 @@ class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] bg-surface-4 border border-solid border-surface-5" :class="[ openDirection === 'up' ? 'shadow-[0_-25px_50px_-12px_rgb(0,0,0,0.25)]' : 'shadow-2xl', + props.dropdownClass, ]" :style="dropdownStyle" :role="listbox ? 'listbox' : 'menu'" @@ -157,7 +158,7 @@ -
+
{{ noOptionsMessage }}
@@ -229,6 +230,8 @@ const props = withDefaults( forceDirection?: 'up' | 'down' noOptionsMessage?: string disableSearchFilter?: boolean + dropdownClass?: string + dropdownMinWidth?: string /** Keep the selected option's label in the input after selection, and show all options on focus */ syncWithSelection?: boolean /** Show a search icon in the searchable input */ @@ -283,6 +286,7 @@ const dropdownStyle = ref({ top: '0px', left: '0px', width: '0px', + minWidth: '0px', }) const openDirection = ref<'down' | 'up'>('down') @@ -426,6 +430,7 @@ async function updateDropdownPosition() { top: `${top}px`, left: `${left}px`, width: `${triggerRect.width}px`, + minWidth: props.dropdownMinWidth ?? `${triggerRect.width}px`, } openDirection.value = direction diff --git a/packages/ui/src/components/servers/ServerListing.vue b/packages/ui/src/components/servers/ServerListing.vue index b14a44429f..89ac8b16f0 100644 --- a/packages/ui/src/components/servers/ServerListing.vue +++ b/packages/ui/src/components/servers/ServerListing.vue @@ -39,6 +39,22 @@

{{ name }}

+
+ + {{ owner.username }} +
void) | null onDownloadBackup?: (() => void) | null + owner?: ServerListingOwner } const props = defineProps() diff --git a/packages/ui/src/components/servers/access/AccessTable.vue b/packages/ui/src/components/servers/access/AccessTable.vue new file mode 100644 index 0000000000..057234cd01 --- /dev/null +++ b/packages/ui/src/components/servers/access/AccessTable.vue @@ -0,0 +1,421 @@ + + + diff --git a/packages/ui/src/components/servers/access/AuditLogTable.vue b/packages/ui/src/components/servers/access/AuditLogTable.vue new file mode 100644 index 0000000000..1b25470217 --- /dev/null +++ b/packages/ui/src/components/servers/access/AuditLogTable.vue @@ -0,0 +1,470 @@ + + + diff --git a/packages/ui/src/components/servers/access/GrantAccessModal.vue b/packages/ui/src/components/servers/access/GrantAccessModal.vue new file mode 100644 index 0000000000..f95ef5b791 --- /dev/null +++ b/packages/ui/src/components/servers/access/GrantAccessModal.vue @@ -0,0 +1,275 @@ + + + diff --git a/packages/ui/src/components/servers/access/index.ts b/packages/ui/src/components/servers/access/index.ts new file mode 100644 index 0000000000..19c342caeb --- /dev/null +++ b/packages/ui/src/components/servers/access/index.ts @@ -0,0 +1,4 @@ +export { default as AccessTable } from './AccessTable.vue' +export { default as AuditLogTable } from './AuditLogTable.vue' +export { default as GrantAccessModal } from './GrantAccessModal.vue' +export * from './types' diff --git a/packages/ui/src/components/servers/access/types.ts b/packages/ui/src/components/servers/access/types.ts new file mode 100644 index 0000000000..f1c61112c1 --- /dev/null +++ b/packages/ui/src/components/servers/access/types.ts @@ -0,0 +1,62 @@ +export type ServerAccessRole = 'owner' | 'editor' | 'viewer' + +export interface ServerAccessUser { + id: string + username: string + avatarUrl?: string +} + +export interface ServerAccessMember { + id: string + user: ServerAccessUser + role: ServerAccessRole + joinedAt: string | null + pending?: boolean + isOwner?: boolean +} + +export type ServerAuditAction = + | { type: 'file_edited'; file: string } + | { type: 'world_started'; worldName: string } + | { type: 'content_installed'; contentType: 'mod' | 'modpack'; name: string; iconUrl?: string } + | { type: 'member_invited' | 'member_removed' | 'role_changed'; target: string } + +export type ServerAuditActionType = ServerAuditAction['type'] + +export interface ServerAuditLogEntry { + id: string + actor: ServerAccessUser | { id: 'support'; username: 'Support' } + world: { id: string; name: string } | null + action: ServerAuditAction + timestamp: string +} + +export interface ServerAuditLogFilters { + userId: string | null + worldId: string | null + actionType: ServerAuditActionType | null +} + +export interface ServerAccessRoleOption { + value: ServerAccessRole + label: string + description?: string +} + +export interface ServerAccessInviteSuggestion { + id: string + username: string + avatarUrl?: string + email?: string +} + +export interface GrantServerAccessPayload { + target: string + role: Exclude + sendFriendRequest: boolean +} + +export interface ServerListingOwner { + username: string + avatarUrl?: string +} diff --git a/packages/ui/src/components/servers/index.ts b/packages/ui/src/components/servers/index.ts index e14e47cd23..d32b0e9092 100644 --- a/packages/ui/src/components/servers/index.ts +++ b/packages/ui/src/components/servers/index.ts @@ -1,3 +1,4 @@ +export * from './access' export * from './admonitions' export * from './backups' export * from './flows' diff --git a/packages/ui/src/components/servers/marketing/MedalServerListing.vue b/packages/ui/src/components/servers/marketing/MedalServerListing.vue index 8ac6731bc1..3171635c4a 100644 --- a/packages/ui/src/components/servers/marketing/MedalServerListing.vue +++ b/packages/ui/src/components/servers/marketing/MedalServerListing.vue @@ -43,6 +43,22 @@ > {{ name }} +
+ + {{ owner.username }} +
() @@ -222,6 +240,14 @@ const messages = defineMessages({ id: 'servers.medal-listing.using-project-label', defaultMessage: 'Using {projectTitle}', }, + ownerTooltip: { + id: 'servers.medal-listing.owner-tooltip', + defaultMessage: 'Owned by {username}', + }, + ownerAvatarAlt: { + id: 'servers.medal-listing.owner-avatar-alt', + defaultMessage: "{username}'s avatar", + }, newServerLabel: { id: 'servers.medal-listing.new-server-label', defaultMessage: 'New server', diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/access.vue b/packages/ui/src/layouts/wrapped/hosting/manage/access.vue new file mode 100644 index 0000000000..5adb405985 --- /dev/null +++ b/packages/ui/src/layouts/wrapped/hosting/manage/access.vue @@ -0,0 +1,439 @@ + + + diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue index a5bf18e625..5c27bc1bc6 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue @@ -143,7 +143,7 @@ />
-
+
- - - - -
{{ formatMessage(messages.noServersFound) }}
+
+

+ {{ formatMessage(messages.yourServersTitle) }} +

+ + + + +
+ {{ formatMessage(messages.noOwnedServersFound) }} +
+
+ +
+

+ {{ formatMessage(messages.sharedServersTitle) }} +

+ + + + +
+ {{ formatMessage(messages.noSharedServersFound) }} +
+
+ +
+ {{ formatMessage(messages.noServersFound) }} +
@@ -220,6 +258,7 @@ import { type ComponentPublicInstance, computed, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import ServersUpgradeModalWrapper from '#ui/components/billing/ServersUpgradeModalWrapper.vue' +import type { ServerListingOwner } from '#ui/components/servers/access' import MedalServerListing from '#ui/components/servers/marketing/MedalServerListing.vue' import ServerListing from '#ui/components/servers/ServerListing.vue' import { createHostingPurchaseIntentContext, provideHostingPurchaseIntent } from '#ui/providers' @@ -270,11 +309,27 @@ const messages = defineMessages({ defaultMessage: 'Search {count} {count, plural, one {server} other {servers}}...', }, newServerButton: { id: 'servers.manage.new-server-button', defaultMessage: 'New server' }, + yourServersTitle: { + id: 'servers.manage.your-servers-title', + defaultMessage: 'Your servers', + }, + sharedServersTitle: { + id: 'servers.manage.shared-servers-title', + defaultMessage: 'Shared servers', + }, checkingForNewServers: { id: 'servers.manage.checking-for-new-servers', defaultMessage: 'Checking for new servers...', }, noServersFound: { id: 'servers.manage.no-servers-found', defaultMessage: 'No servers found.' }, + noOwnedServersFound: { + id: 'servers.manage.no-owned-servers-found', + defaultMessage: 'No servers you own match your search.', + }, + noSharedServersFound: { + id: 'servers.manage.no-shared-servers-found', + defaultMessage: 'No shared servers match your search.', + }, handleErrorTitle: { id: 'servers.manage.handle-error.title', defaultMessage: 'An error occurred', @@ -562,18 +617,51 @@ const serverList = computed(() => { const showEmptyState = computed( () => - !showServersListLoading.value && serverList.value.length === 0 && !isPollingForNewServers.value, + !showServersListLoading.value && + ownedServerList.value.length === 0 && + sharedServerList.value.length === 0 && + !isPollingForNewServers.value, ) const searchInput = ref('') -const fuse = computed(() => { - if (serverList.value.length === 0) return null - return new Fuse(serverList.value, { - keys: ['name', 'loader', 'mc_version', 'game', 'state'], - includeScore: true, - threshold: 0.4, - }) +type ServerWithOwner = Archon.Servers.v0.Server & { owner?: ServerListingOwner } + +const sharedServerOwner: ServerListingOwner = { + username: 'Prospector', +} + +const demoSharedServers = computed(() => { + if (!loggedIn.value) return [] + + const template = + serverList.value.find((server) => server.status !== 'suspended') ?? serverList.value[0] + if (template) { + return [ + { + ...template, + name: template.name || 'A Minecraft Server', + owner: sharedServerOwner, + }, + ] + } + + return [ + { + server_id: 'shared-demo-server', + name: 'A Minecraft Server', + status: 'available', + game: 'Minecraft', + mc_version: '1.21.1', + loader: 'Fabric', + loader_version: '0.16.14', + net: { + domain: 'tough-ghast44', + }, + online: true, + owner: sharedServerOwner, + } as ServerWithOwner, + ] }) function isSetToCancel(server: Archon.Servers.v0.Server): boolean { @@ -611,14 +699,35 @@ function filesExpired(server: Archon.Servers.v0.Server): boolean { return new Date() > thirtyDaysLater } -const filteredData = computed(() => { - const base = !searchInput.value.trim() - ? sortServers(serverList.value) - : fuse.value - ? sortServers(fuse.value.search(searchInput.value).map((result) => result.item)) - : [] - return base.filter((server) => !filesExpired(server)) -}) +const ownedServerList = computed(() => + serverList.value.filter((server) => !filesExpired(server)), +) +const sharedServerList = computed(() => demoSharedServers.value) + +function filterServersBySearch(servers: ServerWithOwner[]): ServerWithOwner[] { + const normalizedSearch = searchInput.value.trim() + if (!normalizedSearch) return sortServers(servers) as ServerWithOwner[] + + const fuse = new Fuse(servers, { + keys: ['name', 'loader', 'mc_version', 'game', 'state', 'owner.username'], + includeScore: true, + threshold: 0.4, + }) + return sortServers( + fuse.search(normalizedSearch).map((result) => result.item), + ) as ServerWithOwner[] +} + +const ownedFilteredData = computed(() => + filterServersBySearch(ownedServerList.value), +) +const sharedFilteredData = computed(() => + filterServersBySearch(sharedServerList.value), +) +const filteredData = computed(() => [ + ...ownedFilteredData.value, + ...sharedFilteredData.value, +]) // Start polling only after initial data is available so the baseline is correct watch(serverResponse, (response) => { diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue index f9159ad088..995a1c6e4e 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue @@ -362,6 +362,7 @@ import { SettingsIcon, TransferIcon, TriangleAlertIcon, + UsersIcon, XIcon, } from '@modrinth/assets' import type { Stats } from '@modrinth/utils' @@ -791,6 +792,12 @@ const navLinks = computed(() => [ icon: DatabaseBackupIcon, subpages: [], }, + { + label: 'Access', + href: `/hosting/manage/${props.serverId}/access`, + icon: UsersIcon, + subpages: [], + }, ...props.additionalTabs, ]) diff --git a/packages/ui/src/layouts/wrapped/index.ts b/packages/ui/src/layouts/wrapped/index.ts index d91f53ee78..6ca4cc12b0 100644 --- a/packages/ui/src/layouts/wrapped/index.ts +++ b/packages/ui/src/layouts/wrapped/index.ts @@ -1,4 +1,5 @@ export { default as ServerOnboardingPanelPage } from './hosting/manage/[id]/onboarding.vue' +export { default as ServersManageAccessPage } from './hosting/manage/access.vue' export { default as ServersManageBackupsPage } from './hosting/manage/backups.vue' export { default as ServersManageContentPage } from './hosting/manage/content.vue' export { default as ServersManageFilesPage } from './hosting/manage/files.vue' diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json index 218450499d..e98dd01b84 100644 --- a/packages/ui/src/locales/en-US/index.json +++ b/packages/ui/src/locales/en-US/index.json @@ -2924,9 +2924,216 @@ "search.server_content_type.vanilla": { "defaultMessage": "Vanilla" }, + "servers.access-page.activity-log-title": { + "defaultMessage": "Activity log" + }, + "servers.access-page.invite-friends": { + "defaultMessage": "Invite friends" + }, + "servers.access-page.notification.invite-cancelled.text": { + "defaultMessage": "Cancelled the invite for {target}." + }, + "servers.access-page.notification.invite-cancelled.title": { + "defaultMessage": "Invite cancelled" + }, + "servers.access-page.notification.invite-resent.text": { + "defaultMessage": "Sent another invite to {target}." + }, + "servers.access-page.notification.invite-resent.title": { + "defaultMessage": "Invite resent" + }, + "servers.access-page.notification.invite-sent.text": { + "defaultMessage": "Invited {target} as {role}." + }, + "servers.access-page.notification.invite-sent.title": { + "defaultMessage": "Invite sent" + }, + "servers.access-page.notification.member-removed.text": { + "defaultMessage": "Removed {target} from this server." + }, + "servers.access-page.notification.member-removed.title": { + "defaultMessage": "Access removed" + }, + "servers.access-page.notification.role-updated.text": { + "defaultMessage": "Changed {target} to {role}." + }, + "servers.access-page.notification.role-updated.title": { + "defaultMessage": "Role updated" + }, + "servers.access-page.role-filter.all": { + "defaultMessage": "Role: All" + }, + "servers.access-page.role.editor": { + "defaultMessage": "Editor" + }, + "servers.access-page.role.editor-description": { + "defaultMessage": "Manage server content, files, backups, and other settings." + }, + "servers.access-page.role.owner": { + "defaultMessage": "Owner" + }, + "servers.access-page.role.owner-description": { + "defaultMessage": "Full access including billing, members, and destructive actions." + }, + "servers.access-page.role.viewer": { + "defaultMessage": "Viewer" + }, + "servers.access-page.role.viewer-description": { + "defaultMessage": "Start, stop, restart, and view the server without making changes." + }, + "servers.access-page.search-users-placeholder": { + "defaultMessage": "Search {count} {count, plural, one {user} other {users}}..." + }, + "servers.access-role.editor": { + "defaultMessage": "Editor" + }, + "servers.access-role.owner": { + "defaultMessage": "Owner" + }, + "servers.access-role.viewer": { + "defaultMessage": "Viewer" + }, + "servers.access-table.action.cancel-invite": { + "defaultMessage": "Cancel invite" + }, + "servers.access-table.action.remove-user": { + "defaultMessage": "Remove user" + }, + "servers.access-table.action.resend-invite": { + "defaultMessage": "Resend invite" + }, + "servers.access-table.column.actions": { + "defaultMessage": "Actions" + }, + "servers.access-table.column.joined": { + "defaultMessage": "Joined" + }, + "servers.access-table.column.role": { + "defaultMessage": "Role" + }, + "servers.access-table.column.user": { + "defaultMessage": "User" + }, + "servers.access-table.empty": { + "defaultMessage": "No users match your filters." + }, + "servers.access-table.member-actions-label": { + "defaultMessage": "Actions for {username}" + }, + "servers.access-table.pending": { + "defaultMessage": "Pending" + }, + "servers.access-table.unknown-joined-date": { + "defaultMessage": "Unknown" + }, + "servers.access-table.user-avatar-alt": { + "defaultMessage": "{username}'s avatar" + }, "servers.admonitions.background-task-running": { "defaultMessage": "Background task running" }, + "servers.audit-log.action.file-edited": { + "defaultMessage": "Edited file: {file}" + }, + "servers.audit-log.action.member-invited": { + "defaultMessage": "Invited user: {target}" + }, + "servers.audit-log.action.member-removed": { + "defaultMessage": "Removed user: {target}" + }, + "servers.audit-log.action.mod-installed": { + "defaultMessage": "Installed mod: {name}" + }, + "servers.audit-log.action.modpack-installed": { + "defaultMessage": "Installed modpack: {name}" + }, + "servers.audit-log.action.role-changed": { + "defaultMessage": "Changed role: {target}" + }, + "servers.audit-log.action.world-started": { + "defaultMessage": "Started world: {worldName}" + }, + "servers.audit-log.actor.support": { + "defaultMessage": "Support" + }, + "servers.audit-log.column.action": { + "defaultMessage": "Action" + }, + "servers.audit-log.column.time": { + "defaultMessage": "Time" + }, + "servers.audit-log.column.user": { + "defaultMessage": "User" + }, + "servers.audit-log.column.world": { + "defaultMessage": "World" + }, + "servers.audit-log.content-icon-alt": { + "defaultMessage": "{name}'s icon" + }, + "servers.audit-log.empty": { + "defaultMessage": "No activity matches your filters." + }, + "servers.audit-log.time-range.all-time": { + "defaultMessage": "All Time" + }, + "servers.audit-log.time-range.last-30-days": { + "defaultMessage": "Last 30 days" + }, + "servers.audit-log.time-range.last-month": { + "defaultMessage": "Last month" + }, + "servers.audit-log.time-range.last-quarter": { + "defaultMessage": "Last quarter" + }, + "servers.audit-log.time-range.last-week": { + "defaultMessage": "Last week" + }, + "servers.audit-log.time-range.last-year": { + "defaultMessage": "Last year" + }, + "servers.audit-log.time-range.previous-12-hours": { + "defaultMessage": "Previous 12 hours" + }, + "servers.audit-log.time-range.previous-24-hours": { + "defaultMessage": "Previous 24 hours" + }, + "servers.audit-log.time-range.previous-30-minutes": { + "defaultMessage": "Previous 30 minutes" + }, + "servers.audit-log.time-range.previous-7-days": { + "defaultMessage": "Previous 7 days" + }, + "servers.audit-log.time-range.previous-hour": { + "defaultMessage": "Previous hour" + }, + "servers.audit-log.time-range.previous-two-years": { + "defaultMessage": "Previous two years" + }, + "servers.audit-log.time-range.previous-year": { + "defaultMessage": "Previous year" + }, + "servers.audit-log.time-range.this-month": { + "defaultMessage": "This month" + }, + "servers.audit-log.time-range.this-quarter": { + "defaultMessage": "This quarter" + }, + "servers.audit-log.time-range.this-week": { + "defaultMessage": "This week" + }, + "servers.audit-log.time-range.this-year": { + "defaultMessage": "This year" + }, + "servers.audit-log.time-range.today": { + "defaultMessage": "Today" + }, + "servers.audit-log.time-range.yesterday": { + "defaultMessage": "Yesterday" + }, + "servers.audit-log.user-avatar-alt": { + "defaultMessage": "{username}'s avatar" + }, "servers.backups.admonition.backup-cancelled.description": { "defaultMessage": "Backup {backupName} was cancelled." }, @@ -3083,6 +3290,51 @@ "servers.busy.syncing-content": { "defaultMessage": "Content sync in progress" }, + "servers.grant-access-modal.cancel": { + "defaultMessage": "Cancel" + }, + "servers.grant-access-modal.friend-request": { + "defaultMessage": "Also send a friend request" + }, + "servers.grant-access-modal.header": { + "defaultMessage": "Invite a friend" + }, + "servers.grant-access-modal.invite": { + "defaultMessage": "Invite" + }, + "servers.grant-access-modal.permissions-help": { + "defaultMessage": "View the full list of permissions for each role here." + }, + "servers.grant-access-modal.role.editor": { + "defaultMessage": "Editor" + }, + "servers.grant-access-modal.role.editor-description": { + "defaultMessage": "Manage server content, files, backups, and other settings." + }, + "servers.grant-access-modal.role.label": { + "defaultMessage": "Select role" + }, + "servers.grant-access-modal.role.viewer": { + "defaultMessage": "Viewer" + }, + "servers.grant-access-modal.role.viewer-description": { + "defaultMessage": "Start, stop, and view the server without making changes." + }, + "servers.grant-access-modal.suggestion-avatar-alt": { + "defaultMessage": "{username}'s avatar" + }, + "servers.grant-access-modal.target.help": { + "defaultMessage": "Use their Modrinth username, or invite a new user by email." + }, + "servers.grant-access-modal.target.label": { + "defaultMessage": "Username or email" + }, + "servers.grant-access-modal.target.no-suggestions": { + "defaultMessage": "Keep typing to invite by email or username." + }, + "servers.grant-access-modal.target.placeholder": { + "defaultMessage": "Enter a username or email" + }, "servers.list-empty.already-have-server-label": { "defaultMessage": "Already have a server?" }, @@ -3179,6 +3431,12 @@ "servers.listing.notice.upgrading": { "defaultMessage": "Your server's hardware is currently being upgraded and will be back online shortly." }, + "servers.listing.owner-avatar-alt": { + "defaultMessage": "{username}'s avatar" + }, + "servers.listing.owner-tooltip": { + "defaultMessage": "Owned by {username}" + }, "servers.listing.resubscribe-label": { "defaultMessage": "Resubscribe" }, @@ -3227,9 +3485,15 @@ "servers.manage.new-server-button": { "defaultMessage": "New server" }, + "servers.manage.no-owned-servers-found": { + "defaultMessage": "No servers you own match your search." + }, "servers.manage.no-servers-found": { "defaultMessage": "No servers found." }, + "servers.manage.no-shared-servers-found": { + "defaultMessage": "No shared servers match your search." + }, "servers.manage.purchase-unavailable.text": { "defaultMessage": "Payment information is still loading. Opening checkout as soon as it is ready." }, @@ -3272,6 +3536,12 @@ "servers.manage.settings-hint.title": { "defaultMessage": "Your server settings have moved" }, + "servers.manage.shared-servers-title": { + "defaultMessage": "Shared servers" + }, + "servers.manage.your-servers-title": { + "defaultMessage": "Your servers" + }, "servers.medal-listing.countdown.remaining": { "defaultMessage": "{days} {days, plural, one {day} other {days}} {hours} {hours, plural, one {hour} other {hours}} {minutes} {minutes, plural, one {minute} other {minutes}} {seconds} {seconds, plural, one {second} other {seconds}} remaining..." }, @@ -3290,6 +3560,12 @@ "servers.medal-listing.notice.upgrading": { "defaultMessage": "Your server's hardware is currently being upgraded and will be back online shortly." }, + "servers.medal-listing.owner-avatar-alt": { + "defaultMessage": "{username}'s avatar" + }, + "servers.medal-listing.owner-tooltip": { + "defaultMessage": "Owned by {username}" + }, "servers.medal-listing.server-icon-alt": { "defaultMessage": "Server icon" }, diff --git a/packages/ui/src/stories/servers/AccessTable.stories.ts b/packages/ui/src/stories/servers/AccessTable.stories.ts new file mode 100644 index 0000000000..12ddd6624c --- /dev/null +++ b/packages/ui/src/stories/servers/AccessTable.stories.ts @@ -0,0 +1,135 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' + +import AccessTable from '../../components/servers/access/AccessTable.vue' +import type { + ServerAccessMember, + ServerAccessRole, + ServerAccessRoleOption, +} from '../../components/servers/access/types' + +const roleOptions: ServerAccessRoleOption[] = [ + { + value: 'owner', + label: 'Owner', + description: 'Full access including billing, members, and destructive actions.', + }, + { + value: 'editor', + label: 'Editor', + description: 'Manage server content, files, backups, and other settings.', + }, + { + value: 'viewer', + label: 'Viewer', + description: 'Start, stop, restart, and view the server without making changes.', + }, +] + +const members: ServerAccessMember[] = [ + { + id: 'owner', + user: { id: 'prospector', username: 'Prospector' }, + role: 'owner', + joinedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + isOwner: true, + }, + { + id: 'editor', + user: { id: 'geometrically', username: 'Geometrically' }, + role: 'editor', + joinedAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString(), + }, + { + id: 'pending', + user: { id: 'imb', username: 'IMB' }, + role: 'viewer', + joinedAt: null, + pending: true, + }, +] + +const meta = { + title: 'Servers/AccessTable', + component: AccessTable, + parameters: { + layout: 'padded', + }, + decorators: [ + (story) => ({ + components: { story }, + template: '
', + }), + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { AccessTable }, + setup() { + const rows = ref([...members]) + function updateRole(member: ServerAccessMember, role: ServerAccessRole) { + rows.value = rows.value.map((row) => (row.id === member.id ? { ...row, role } : row)) + } + function removeMember(member: ServerAccessMember) { + rows.value = rows.value.filter((row) => row.id !== member.id) + } + return { rows, roleOptions, updateRole, removeMember } + }, + template: /* html */ ` + + `, + }), +} + +export const PendingInvite: Story = { + args: { + members: [members[2]], + roles: roleOptions, + }, +} + +export const OwnerFixed: Story = { + args: { + members: [members[0]], + roles: roleOptions, + }, +} + +export const MobileCompact: Story = { + render: () => ({ + components: { AccessTable }, + setup() { + const rows = ref([...members]) + function updateRole(member: ServerAccessMember, role: ServerAccessRole) { + rows.value = rows.value.map((row) => (row.id === member.id ? { ...row, role } : row)) + } + function removeMember(member: ServerAccessMember) { + rows.value = rows.value.filter((row) => row.id !== member.id) + } + return { rows, roleOptions, updateRole, removeMember } + }, + template: /* html */ ` +
+ +
+ `, + }), +} diff --git a/packages/ui/src/stories/servers/AuditLogTable.stories.ts b/packages/ui/src/stories/servers/AuditLogTable.stories.ts new file mode 100644 index 0000000000..0528b82ce0 --- /dev/null +++ b/packages/ui/src/stories/servers/AuditLogTable.stories.ts @@ -0,0 +1,145 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' + +import AuditLogTable from '../../components/servers/access/AuditLogTable.vue' +import type { + ServerAccessMember, + ServerAuditLogEntry, + ServerAuditLogFilters, +} from '../../components/servers/access/types' + +const users: ServerAccessMember[] = [ + { + id: 'owner', + user: { id: 'prospector', username: 'Prospector' }, + role: 'owner', + joinedAt: new Date().toISOString(), + isOwner: true, + }, + { + id: 'editor', + user: { id: 'geometrically', username: 'Geometrically' }, + role: 'editor', + joinedAt: new Date().toISOString(), + }, +] + +const worlds = [ + { id: 'create-smp', name: 'Create SMP' }, + { id: 'smp-season-4', name: 'SMP Season 4' }, +] + +const entries: ServerAuditLogEntry[] = [ + { + id: 'support', + actor: { id: 'support', username: 'Support' }, + world: null, + action: { type: 'file_edited', file: 'server.properties' }, + timestamp: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + }, + { + id: 'world', + actor: users[1].user, + world: null, + action: { type: 'world_started', worldName: 'Create SMP' }, + timestamp: new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(), + }, + { + id: 'mod', + actor: users[1].user, + world: worlds[1], + action: { type: 'content_installed', contentType: 'mod', name: 'Create Aeronautics' }, + timestamp: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(), + }, +] + +const meta = { + title: 'Servers/AuditLogTable', + component: AuditLogTable, + parameters: { + layout: 'padded', + }, + decorators: [ + (story) => ({ + components: { story }, + template: '
', + }), + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { AuditLogTable }, + setup() { + const query = ref('') + const filters = ref({ + userId: null, + worldId: null, + actionType: null, + }) + return { entries, users, worlds, query, filters } + }, + template: /* html */ ` + + `, + }), +} + +export const Filtered: Story = { + render: () => ({ + components: { AuditLogTable }, + setup() { + const query = ref('server.properties') + const filters = ref({ + userId: null, + worldId: null, + actionType: 'file_edited', + }) + return { entries, users, worlds, query, filters } + }, + template: /* html */ ` + + `, + }), +} + +export const MobileCompact: Story = { + render: () => ({ + components: { AuditLogTable }, + setup() { + const query = ref('') + const filters = ref({ + userId: null, + worldId: null, + actionType: null, + }) + return { entries, users, worlds, query, filters } + }, + template: /* html */ ` +
+ +
+ `, + }), +} diff --git a/packages/ui/src/stories/servers/GrantAccessModal.stories.ts b/packages/ui/src/stories/servers/GrantAccessModal.stories.ts new file mode 100644 index 0000000000..a827475d70 --- /dev/null +++ b/packages/ui/src/stories/servers/GrantAccessModal.stories.ts @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' + +import ButtonStyled from '../../components/base/ButtonStyled.vue' +import GrantAccessModal from '../../components/servers/access/GrantAccessModal.vue' +import type { GrantServerAccessPayload } from '../../components/servers/access/types' + +const meta = { + title: 'Servers/GrantAccessModal', + component: GrantAccessModal, + parameters: { + layout: 'centered', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { ButtonStyled, GrantAccessModal }, + setup() { + const modalRef = ref | null>(null) + const lastInvite = ref('') + const suggestions = [ + { id: 'fetch', username: 'Fetch', email: 'fetch@example.com' }, + { id: 'emma', username: 'Emma', email: 'emma@example.com' }, + ] + function handleGrant(payload: GrantServerAccessPayload) { + lastInvite.value = `${payload.target} as ${payload.role}` + } + return { modalRef, suggestions, lastInvite, handleGrant } + }, + template: /* html */ ` +
+ + + +

Last invite: {{ lastInvite }}

+ +
+ `, + }), +} diff --git a/packages/ui/src/stories/servers/ServerListing.stories.ts b/packages/ui/src/stories/servers/ServerListing.stories.ts index d1fff61904..c890a6fba3 100644 --- a/packages/ui/src/stories/servers/ServerListing.stories.ts +++ b/packages/ui/src/stories/servers/ServerListing.stories.ts @@ -50,6 +50,16 @@ export const Default: Story = { }, } +export const SharedWithOwner: Story = { + args: { + ...baseServer, + name: 'Cobbletown', + owner: { + username: 'Prospector', + }, + }, +} + export const ConfiguringNewServer: Story = { args: { ...baseServer, From 7ca70af54d95b707350b5b15eb68917a5d17e89d Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Mon, 4 May 2026 21:39:14 +0100 Subject: [PATCH 2/4] fix: spacing --- packages/ui/src/components/servers/ServerListing.vue | 2 +- .../ui/src/components/servers/access/GrantAccessModal.vue | 4 ++-- packages/ui/src/layouts/wrapped/hosting/manage/index.vue | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/servers/ServerListing.vue b/packages/ui/src/components/servers/ServerListing.vue index 89ac8b16f0..aa18d7b413 100644 --- a/packages/ui/src/components/servers/ServerListing.vue +++ b/packages/ui/src/components/servers/ServerListing.vue @@ -42,7 +42,7 @@
-

+ {{ formatMessage(messages.targetHelp) }} -

+
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue index 5c27bc1bc6..575e5e536b 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue @@ -629,6 +629,7 @@ type ServerWithOwner = Archon.Servers.v0.Server & { owner?: ServerListingOwner } const sharedServerOwner: ServerListingOwner = { username: 'Prospector', + avatarUrl: 'https://github.com/Prospector.png', } const demoSharedServers = computed(() => { From f6378f71965b015e7e87998cf69b4c7e12f7b0f3 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Wed, 6 May 2026 21:17:59 +0100 Subject: [PATCH 3/4] feat: qa --- packages/ui/src/components/base/Combobox.vue | 21 +- .../src/components/servers/ServerListing.vue | 2 +- .../components/servers/access/AccessTable.vue | 195 ++++++++-- .../servers/access/AuditLogTable.vue | 337 ++++++++++++++++-- .../servers/access/GrantAccessModal.vue | 30 +- .../servers/access/RemoveAccessModal.vue | 120 +++++++ .../ui/src/components/servers/access/index.ts | 1 + .../ui/src/components/servers/access/types.ts | 14 +- .../servers/marketing/MedalServerListing.vue | 2 +- .../layouts/wrapped/hosting/manage/access.vue | 107 +++++- packages/ui/src/locales/en-US/index.json | 82 ++++- .../ui/src/stories/base/Combobox.stories.ts | 15 + .../stories/servers/AccessTable.stories.ts | 14 +- .../stories/servers/AuditLogTable.stories.ts | 15 +- .../servers/GrantAccessModal.stories.ts | 2 +- .../servers/MedalServerListing.stories.ts | 10 + .../servers/RemoveAccessModal.stories.ts | 67 ++++ 17 files changed, 930 insertions(+), 104 deletions(-) create mode 100644 packages/ui/src/components/servers/access/RemoveAccessModal.vue create mode 100644 packages/ui/src/stories/servers/RemoveAccessModal.stories.ts diff --git a/packages/ui/src/components/base/Combobox.vue b/packages/ui/src/components/base/Combobox.vue index 52746bd8e2..18490dfe44 100644 --- a/packages/ui/src/components/base/Combobox.vue +++ b/packages/ui/src/components/base/Combobox.vue @@ -232,6 +232,7 @@ const props = withDefaults( disableSearchFilter?: boolean dropdownClass?: string dropdownMinWidth?: string + minSearchLengthToOpen?: number /** Keep the selected option's label in the input after selection, and show all options on focus */ syncWithSelection?: boolean /** Show a search icon in the searchable input */ @@ -247,6 +248,7 @@ const props = withDefaults( showIconInSelected: false, maxHeight: DEFAULT_MAX_HEIGHT, noOptionsMessage: 'No results found', + minSearchLengthToOpen: 0, syncWithSelection: true, showSearchIcon: false, }, @@ -320,6 +322,12 @@ const triggerText = computed(() => { return props.placeholder }) +const hasMinimumSearchLength = computed( + () => + !props.searchable || + searchQuery.value.trim().length >= props.minSearchLengthToOpen, +) + const optionsWithKeys = computed(() => { return props.options.map((opt, index) => ({ ...opt, @@ -438,6 +446,7 @@ async function updateDropdownPosition() { async function openDropdown() { if (props.disabled || isOpen.value) return + if (!hasMinimumSearchLength.value) return isOpen.value = true emit('open') @@ -627,6 +636,10 @@ function handleSearchKeydown(event: KeyboardEvent) { function handleSearchInput() { userHasTyped.value = true emit('searchInput', searchQuery.value) + if (!hasMinimumSearchLength.value) { + closeDropdown() + return + } if (!isOpen.value) { openDropdown() } @@ -694,10 +707,16 @@ watch(filteredOptions, () => { } }) +watch(hasMinimumSearchLength, (canOpen) => { + if (!canOpen) { + closeDropdown() + } +}) + watch( [() => props.modelValue, () => props.options], ([val]) => { - if (props.searchable && props.syncWithSelection && !isOpen.value) { + if (props.searchable && props.syncWithSelection && !isOpen.value && !userHasTyped.value) { const opt = props.options.find((o) => isDropdownOption(o) && o.value === val) searchQuery.value = opt && isDropdownOption(opt) ? opt.label : '' } diff --git a/packages/ui/src/components/servers/ServerListing.vue b/packages/ui/src/components/servers/ServerListing.vue index aa18d7b413..8f1c52fe96 100644 --- a/packages/ui/src/components/servers/ServerListing.vue +++ b/packages/ui/src/components/servers/ServerListing.vue @@ -42,7 +42,7 @@
@@ -149,11 +149,12 @@ const modal = ref | null>(null) const target = ref('') const selectedRole = ref>('editor') const sendFriendRequest = ref(true) +const suggestionMinimumLength = 2 const messages = defineMessages({ header: { id: 'servers.grant-access-modal.header', - defaultMessage: 'Invite a friend', + defaultMessage: 'Invite a user', }, targetLabel: { id: 'servers.grant-access-modal.target.label', @@ -165,7 +166,7 @@ const messages = defineMessages({ }, noSuggestions: { id: 'servers.grant-access-modal.target.no-suggestions', - defaultMessage: 'Keep typing to invite by email or username.', + defaultMessage: 'No matching users found.', }, targetHelp: { id: 'servers.grant-access-modal.target.help', @@ -185,7 +186,7 @@ const messages = defineMessages({ }, viewerRole: { id: 'servers.grant-access-modal.role.viewer', - defaultMessage: 'Viewer', + defaultMessage: 'Limited', }, viewerDescription: { id: 'servers.grant-access-modal.role.viewer-description', @@ -228,17 +229,16 @@ const grantableRoles = computed(() => [ }, ]) +const normalizedTarget = computed(() => target.value.trim()) +const canInvite = computed(() => normalizedTarget.value.length > 0 && !!selectedRole.value) const suggestionOptions = computed[]>(() => props.suggestions.map((suggestion) => ({ value: suggestion.username, label: suggestion.username, - searchTerms: [suggestion.username], + searchTerms: [suggestion.username, suggestion.email].filter(Boolean) as string[], })), ) -const normalizedTarget = computed(() => target.value.trim()) -const canInvite = computed(() => normalizedTarget.value.length > 0 && !!selectedRole.value) - function findSuggestion(value: string) { return props.suggestions.find( (suggestion) => suggestion.username === value || suggestion.email === value, diff --git a/packages/ui/src/components/servers/access/RemoveAccessModal.vue b/packages/ui/src/components/servers/access/RemoveAccessModal.vue new file mode 100644 index 0000000000..82d902d6b5 --- /dev/null +++ b/packages/ui/src/components/servers/access/RemoveAccessModal.vue @@ -0,0 +1,120 @@ + + + diff --git a/packages/ui/src/components/servers/access/index.ts b/packages/ui/src/components/servers/access/index.ts index 19c342caeb..8f871d4e48 100644 --- a/packages/ui/src/components/servers/access/index.ts +++ b/packages/ui/src/components/servers/access/index.ts @@ -1,4 +1,5 @@ export { default as AccessTable } from './AccessTable.vue' export { default as AuditLogTable } from './AuditLogTable.vue' export { default as GrantAccessModal } from './GrantAccessModal.vue' +export { default as RemoveAccessModal } from './RemoveAccessModal.vue' export * from './types' diff --git a/packages/ui/src/components/servers/access/types.ts b/packages/ui/src/components/servers/access/types.ts index f1c61112c1..67fb729291 100644 --- a/packages/ui/src/components/servers/access/types.ts +++ b/packages/ui/src/components/servers/access/types.ts @@ -11,6 +11,7 @@ export interface ServerAccessMember { user: ServerAccessUser role: ServerAccessRole joinedAt: string | null + inviteResendAvailableAt?: string | null pending?: boolean isOwner?: boolean } @@ -18,8 +19,17 @@ export interface ServerAccessMember { export type ServerAuditAction = | { type: 'file_edited'; file: string } | { type: 'world_started'; worldName: string } - | { type: 'content_installed'; contentType: 'mod' | 'modpack'; name: string; iconUrl?: string } - | { type: 'member_invited' | 'member_removed' | 'role_changed'; target: string } + | { + type: 'content_installed' + contentType: 'mod' | 'modpack' + name: string + iconUrl?: string + href?: string + version?: string + } + | { type: 'member_invited'; target: string; role?: Exclude } + | { type: 'member_removed'; target: string } + | { type: 'role_changed'; target: string; role?: ServerAccessRole } export type ServerAuditActionType = ServerAuditAction['type'] diff --git a/packages/ui/src/components/servers/marketing/MedalServerListing.vue b/packages/ui/src/components/servers/marketing/MedalServerListing.vue index 3171635c4a..cb9b1c4c1c 100644 --- a/packages/ui/src/components/servers/marketing/MedalServerListing.vue +++ b/packages/ui/src/components/servers/marketing/MedalServerListing.vue @@ -46,7 +46,7 @@
+ > + + +

Last invite: {{ lastInvite }}

diff --git a/packages/ui/src/stories/servers/MedalServerListing.stories.ts b/packages/ui/src/stories/servers/MedalServerListing.stories.ts index 9073f55465..b404f9ef13 100644 --- a/packages/ui/src/stories/servers/MedalServerListing.stories.ts +++ b/packages/ui/src/stories/servers/MedalServerListing.stories.ts @@ -41,6 +41,16 @@ export const Default: Story = { }, } +export const SharedWithOwner: Story = { + args: { + ...baseMedalServer, + name: 'Medal Co-op Server', + owner: { + username: 'Prospector', + }, + }, +} + export const ConfiguringNewServer: Story = { args: { ...baseMedalServer, diff --git a/packages/ui/src/stories/servers/RemoveAccessModal.stories.ts b/packages/ui/src/stories/servers/RemoveAccessModal.stories.ts new file mode 100644 index 0000000000..29f9c41d97 --- /dev/null +++ b/packages/ui/src/stories/servers/RemoveAccessModal.stories.ts @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' + +import ButtonStyled from '../../components/base/ButtonStyled.vue' +import RemoveAccessModal from '../../components/servers/access/RemoveAccessModal.vue' + +const meta = { + title: 'Servers/RemoveAccessModal', + component: RemoveAccessModal, + parameters: { + layout: 'centered', + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { ButtonStyled, RemoveAccessModal }, + setup() { + const modalRef = ref | null>(null) + const removed = ref(false) + function handleRemove() { + removed.value = true + } + return { modalRef, removed, handleRemove } + }, + template: /* html */ ` +
+ + + +

User removed

+ +
+ `, + }), +} + +export const CancelInvite: Story = { + render: () => ({ + components: { ButtonStyled, RemoveAccessModal }, + setup() { + const modalRef = ref | null>(null) + const cancelled = ref(false) + function handleCancel() { + cancelled.value = true + } + return { modalRef, cancelled, handleCancel } + }, + template: /* html */ ` +
+ + + +

Invite cancelled

+ +
+ `, + }), +} From 28561bd3898c0e64e3e7a5256f6b5afccf0d0c8d Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Wed, 6 May 2026 21:43:51 +0100 Subject: [PATCH 4/4] feat: implement backend --- .../src/modules/archon/server-users/v1.ts | 65 +++ .../api-client/src/modules/archon/types.ts | 22 + packages/api-client/src/modules/index.ts | 2 + .../servers/access/AuditLogTable.vue | 183 ++++--- .../servers/access/GrantAccessModal.vue | 25 +- .../ui/src/components/servers/access/types.ts | 1 - .../layouts/wrapped/hosting/manage/access.vue | 513 ++++++++++-------- .../stories/servers/AuditLogTable.stories.ts | 27 + .../servers/GrantAccessModal.stories.ts | 14 +- 9 files changed, 546 insertions(+), 306 deletions(-) create mode 100644 packages/api-client/src/modules/archon/server-users/v1.ts diff --git a/packages/api-client/src/modules/archon/server-users/v1.ts b/packages/api-client/src/modules/archon/server-users/v1.ts new file mode 100644 index 0000000000..cf6df581a2 --- /dev/null +++ b/packages/api-client/src/modules/archon/server-users/v1.ts @@ -0,0 +1,65 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonServerUsersV1Module extends AbstractModule { + public getModuleID(): string { + return 'archon_server_users_v1' + } + + /** + * Get list of users with access to a server + * GET /v1/servers/:server_id/users + */ + public async list(serverId: string): Promise { + return this.client.request(`/servers/${serverId}/users`, { + api: 'archon', + version: 1, + method: 'GET', + }) + } + + /** + * Add a user to a server + * POST /v1/servers/:server_id/users + */ + public async add( + serverId: string, + user: Archon.ServerUsers.v1.AddServerUserRequest, + ): Promise { + await this.client.request(`/servers/${serverId}/users`, { + api: 'archon', + version: 1, + method: 'POST', + body: user, + }) + } + + /** + * Remove a user from a server + * DELETE /v1/servers/:server_id/users/:user_id + */ + public async delete(serverId: string, userId: string): Promise { + await this.client.request(`/servers/${serverId}/users/${userId}`, { + api: 'archon', + version: 1, + method: 'DELETE', + }) + } + + /** + * Update a user's server role + * PATCH /v1/servers/:server_id/users/:user_id + */ + public async update( + serverId: string, + userId: string, + role: Archon.ServerUsers.v1.AssignableServerUserRole, + ): Promise { + await this.client.request(`/servers/${serverId}/users/${userId}`, { + api: 'archon', + version: 1, + method: 'PATCH', + body: role, + }) + } +} diff --git a/packages/api-client/src/modules/archon/types.ts b/packages/api-client/src/modules/archon/types.ts index b6435c0be2..b95f664096 100644 --- a/packages/api-client/src/modules/archon/types.ts +++ b/packages/api-client/src/modules/archon/types.ts @@ -220,6 +220,28 @@ export namespace Archon { } } + export namespace ServerUsers { + export namespace v1 { + export type ServerUserRole = 'Owner' | 'Editor' | 'Viewer' | 'Unknown' + + export type AssignableServerUserRole = Exclude + + export type ServerUser = { + server_id: string | null + user_id: string + added_on: string | null + role: ServerUserRole + } + + export type AddServerUserRequest = { + server_id?: string | null + user_id: string + added_on?: string | null + role: AssignableServerUserRole + } + } + } + export namespace Servers { export namespace v0 { export type ServerGetResponse = { diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts index c1b9b13fef..a7abc6ff5a 100644 --- a/packages/api-client/src/modules/index.ts +++ b/packages/api-client/src/modules/index.ts @@ -5,6 +5,7 @@ import { ArchonBackupsQueueV1Module } from './archon/backups-queue/v1' import { ArchonContentV1Module } from './archon/content/v1' import { ArchonOptionsV1Module } from './archon/options/v1' import { ArchonPropertiesV1Module } from './archon/properties/v1' +import { ArchonServerUsersV1Module } from './archon/server-users/v1' import { ArchonServersV0Module } from './archon/servers/v0' import { ArchonServersV1Module } from './archon/servers/v1' import { ISO3166Module } from './iso3166' @@ -61,6 +62,7 @@ export const MODULE_REGISTRY = { archon_content_v1: ArchonContentV1Module, archon_options_v1: ArchonOptionsV1Module, archon_properties_v1: ArchonPropertiesV1Module, + archon_server_users_v1: ArchonServerUsersV1Module, archon_servers_v0: ArchonServersV0Module, archon_servers_v1: ArchonServersV1Module, iso3166_data: ISO3166Module, diff --git a/packages/ui/src/components/servers/access/AuditLogTable.vue b/packages/ui/src/components/servers/access/AuditLogTable.vue index b846a6c0a0..072a24345f 100644 --- a/packages/ui/src/components/servers/access/AuditLogTable.vue +++ b/packages/ui/src/components/servers/access/AuditLogTable.vue @@ -62,7 +62,10 @@ @@ -173,8 +178,9 @@
- - {{ formatAction(entry.action) }} - +
- {{ formatMessage(messages.emptyState) }} + {{ formatMessage(emptyStateMessage) }}
@@ -441,6 +451,10 @@ const messages = defineMessages({ id: 'servers.audit-log.empty', defaultMessage: 'No activity matches your filters.', }, + noActivityEmptyState: { + id: 'servers.audit-log.empty.no-activity', + defaultMessage: 'Perform an action on your server and you will see it here!', + }, userAvatarAlt: { id: 'servers.audit-log.user-avatar-alt', defaultMessage: "{username}'s avatar", @@ -449,25 +463,37 @@ const messages = defineMessages({ id: 'servers.audit-log.content-icon-alt', defaultMessage: "{name}'s icon", }, - modInstalledPrefix: { - id: 'servers.audit-log.action-prefix.mod-installed', - defaultMessage: 'Installed mod:', + editedVerb: { + id: 'servers.audit-log.action-verb.edited', + defaultMessage: 'Edited', + }, + startedVerb: { + id: 'servers.audit-log.action-verb.started', + defaultMessage: 'Started', + }, + installedVerb: { + id: 'servers.audit-log.action-verb.installed', + defaultMessage: 'Installed', }, - modpackInstalledPrefix: { - id: 'servers.audit-log.action-prefix.modpack-installed', - defaultMessage: 'Installed modpack:', + invitedVerb: { + id: 'servers.audit-log.action-verb.invited', + defaultMessage: 'Invited', }, - memberInvitedPrefix: { - id: 'servers.audit-log.action-prefix.member-invited', - defaultMessage: 'Invited user:', + removedVerb: { + id: 'servers.audit-log.action-verb.removed', + defaultMessage: 'Removed', }, - memberRemovedPrefix: { - id: 'servers.audit-log.action-prefix.member-removed', - defaultMessage: 'Removed user:', + changedVerb: { + id: 'servers.audit-log.action-verb.changed', + defaultMessage: 'Changed', }, - roleChangedPrefix: { - id: 'servers.audit-log.action-prefix.role-changed', - defaultMessage: 'Changed role:', + modContentType: { + id: 'servers.audit-log.content-type.mod', + defaultMessage: 'mod', + }, + modpackContentType: { + id: 'servers.audit-log.content-type.modpack', + defaultMessage: 'modpack', }, memberInvitedRoleSuffix: { id: 'servers.audit-log.action-suffix.member-invited-role', @@ -479,31 +505,31 @@ const messages = defineMessages({ }, fileEditedAction: { id: 'servers.audit-log.action.file-edited', - defaultMessage: 'Edited file: {file}', + defaultMessage: 'Edited: {file}', }, worldStartedAction: { id: 'servers.audit-log.action.world-started', - defaultMessage: 'Started world: {worldName}', + defaultMessage: 'Started: {worldName}', }, modInstalledAction: { id: 'servers.audit-log.action.mod-installed', - defaultMessage: 'Installed mod: {name}{version}', + defaultMessage: 'Installed: mod {name}{version}', }, modpackInstalledAction: { id: 'servers.audit-log.action.modpack-installed', - defaultMessage: 'Installed modpack: {name}{version}', + defaultMessage: 'Installed: modpack {name}{version}', }, memberInvitedAction: { id: 'servers.audit-log.action.member-invited', - defaultMessage: 'Invited user: {target}{role}', + defaultMessage: 'Invited: {target}{role}', }, memberRemovedAction: { id: 'servers.audit-log.action.member-removed', - defaultMessage: 'Removed user: {target}', + defaultMessage: 'Removed: {target}', }, roleChangedAction: { id: 'servers.audit-log.action.role-changed', - defaultMessage: 'Changed role: {target}{role}', + defaultMessage: 'Changed: {target}{role}', }, ownerRole: { id: 'servers.audit-log.role.owner', @@ -590,6 +616,20 @@ const filteredEntries = computed(() => { const tableEntries = computed(() => filteredEntries.value as AuditLogTableRow[]) +const hasActiveFilters = computed( + () => + query.value.trim().length > 0 || + !!filters.value.userId || + !!filters.value.worldId || + !!filters.value.actionType, +) + +const emptyStateMessage = computed(() => + props.entries.length === 0 && !hasActiveFilters.value + ? messages.noActivityEmptyState + : messages.emptyState, +) + function formatAction(action: ServerAuditAction): string { switch (action.type) { case 'file_edited': @@ -609,14 +649,14 @@ function formatAction(action: ServerAuditAction): string { case 'member_invited': return formatMessage(messages.memberInvitedAction, { target: action.target, - role: action.role ? ` as ${formatRole(action.role)}` : '', + role: memberActionSuffix(action) ? ` ${memberActionSuffix(action)}` : '', }) case 'member_removed': return formatMessage(messages.memberRemovedAction, { target: action.target }) case 'role_changed': return formatMessage(messages.roleChangedAction, { target: action.target, - role: action.role ? ` to ${formatRole(action.role)}` : '', + role: memberActionSuffix(action) ? ` ${memberActionSuffix(action)}` : '', }) } } @@ -638,11 +678,45 @@ function actionSearchValue(action: ServerAuditAction): string { } } -function contentInstalledPrefix(action: ServerAuditAction): string { - if (action.type !== 'content_installed') return '' - return action.contentType === 'mod' - ? formatMessage(messages.modInstalledPrefix) - : formatMessage(messages.modpackInstalledPrefix) +function actionVerb(action: ServerAuditAction): string { + switch (action.type) { + case 'file_edited': + return formatMessage(messages.editedVerb) + case 'world_started': + return formatMessage(messages.startedVerb) + case 'content_installed': + return formatMessage(messages.installedVerb) + case 'member_invited': + return formatMessage(messages.invitedVerb) + case 'member_removed': + return formatMessage(messages.removedVerb) + case 'role_changed': + return formatMessage(messages.changedVerb) + } +} + +function actionMetadata(action: ServerAuditAction): string { + switch (action.type) { + case 'file_edited': + return action.file + case 'world_started': + return action.worldName + case 'content_installed': + return `${contentTypeLabel(action.contentType)} ${action.name}${formatVersionSuffix(action.version)}` + case 'member_invited': + case 'role_changed': + return `${action.target}${memberActionSuffix(action) ? ` ${memberActionSuffix(action)}` : ''}` + case 'member_removed': + return action.target + } +} + +function contentTypeLabel( + contentType: Extract['contentType'], +) { + return contentType === 'mod' + ? formatMessage(messages.modContentType) + : formatMessage(messages.modpackContentType) } function isMemberAuditAction(action: ServerAuditAction): action is MemberAuditAction { @@ -653,19 +727,6 @@ function isMemberAuditAction(action: ServerAuditAction): action is MemberAuditAc ) } -function memberActionPrefix(action: MemberAuditAction): string { - switch (action.type) { - case 'member_invited': - return formatMessage(messages.memberInvitedPrefix) - case 'member_removed': - return formatMessage(messages.memberRemovedPrefix) - case 'role_changed': - return formatMessage(messages.roleChangedPrefix) - default: - return '' - } -} - function memberActionSuffix(action: MemberAuditAction): string { switch (action.type) { case 'member_invited': diff --git a/packages/ui/src/components/servers/access/GrantAccessModal.vue b/packages/ui/src/components/servers/access/GrantAccessModal.vue index 76b938f021..79fb58dc75 100644 --- a/packages/ui/src/components/servers/access/GrantAccessModal.vue +++ b/packages/ui/src/components/servers/access/GrantAccessModal.vue @@ -91,13 +91,6 @@

- - diff --git a/packages/ui/src/stories/servers/AuditLogTable.stories.ts b/packages/ui/src/stories/servers/AuditLogTable.stories.ts index ec401ddb73..d0fd7ca9b8 100644 --- a/packages/ui/src/stories/servers/AuditLogTable.stories.ts +++ b/packages/ui/src/stories/servers/AuditLogTable.stories.ts @@ -57,6 +57,33 @@ const entries: ServerAuditLogEntry[] = [ }, timestamp: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(), }, + { + id: 'modpack', + actor: users[1].user, + world: worlds[1], + action: { + type: 'content_installed', + contentType: 'modpack', + name: 'Cobblemon x Create', + href: '/modpack/cobblemon-x-create', + version: '2.1.4', + }, + timestamp: new Date(Date.now() - 6.5 * 60 * 60 * 1000).toISOString(), + }, + { + id: 'member-invited', + actor: users[0].user, + world: null, + action: { type: 'member_invited', target: 'IMB', role: 'viewer' }, + timestamp: new Date(Date.now() - 6.75 * 60 * 60 * 1000).toISOString(), + }, + { + id: 'member-removed', + actor: users[0].user, + world: null, + action: { type: 'member_removed', target: 'Fetch' }, + timestamp: new Date(Date.now() - 6.85 * 60 * 60 * 1000).toISOString(), + }, { id: 'role-change', actor: users[0].user, diff --git a/packages/ui/src/stories/servers/GrantAccessModal.stories.ts b/packages/ui/src/stories/servers/GrantAccessModal.stories.ts index 5efb1f5064..7a003b470a 100644 --- a/packages/ui/src/stories/servers/GrantAccessModal.stories.ts +++ b/packages/ui/src/stories/servers/GrantAccessModal.stories.ts @@ -21,22 +21,22 @@ export const Default: Story = { components: { ButtonStyled, GrantAccessModal }, setup() { const modalRef = ref | null>(null) - const lastInvite = ref('') + const lastAddedUser = ref('') const suggestions = [ - { id: 'fetch', username: 'Fetch', email: 'fetch@example.com' }, - { id: 'emma', username: 'Emma', email: 'emma@example.com' }, + { id: 'fetch', username: 'Fetch' }, + { id: 'emma', username: 'Emma' }, ] function handleGrant(payload: GrantServerAccessPayload) { - lastInvite.value = `${payload.target} as ${payload.role}` + lastAddedUser.value = `${payload.target} as ${payload.role}` } - return { modalRef, suggestions, lastInvite, handleGrant } + return { modalRef, suggestions, lastAddedUser, handleGrant } }, template: /* html */ `
- + -

Last invite: {{ lastInvite }}

+

Last added: {{ lastAddedUser }}

`,