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/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/assets/external/illustrations/intercom_bubble_icon.png b/packages/assets/external/illustrations/intercom_bubble_icon.png new file mode 100644 index 0000000000..6585b9b09e Binary files /dev/null and b/packages/assets/external/illustrations/intercom_bubble_icon.png differ diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts index bce13e0dc2..eb191354ae 100644 --- a/packages/assets/generated-icons.ts +++ b/packages/assets/generated-icons.ts @@ -3,8 +3,6 @@ import type { FunctionalComponent, SVGAttributes } from 'vue' -export type IconComponent = FunctionalComponent - 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..18490dfe44 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,9 @@ const props = withDefaults( forceDirection?: 'up' | 'down' noOptionsMessage?: string 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 */ @@ -244,6 +248,7 @@ const props = withDefaults( showIconInSelected: false, maxHeight: DEFAULT_MAX_HEIGHT, noOptionsMessage: 'No results found', + minSearchLengthToOpen: 0, syncWithSelection: true, showSearchIcon: false, }, @@ -283,6 +288,7 @@ const dropdownStyle = ref({ top: '0px', left: '0px', width: '0px', + minWidth: '0px', }) const openDirection = ref<'down' | 'up'>('down') @@ -316,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, @@ -426,6 +438,7 @@ async function updateDropdownPosition() { top: `${top}px`, left: `${left}px`, width: `${triggerRect.width}px`, + minWidth: props.dropdownMinWidth ?? `${triggerRect.width}px`, } openDirection.value = direction @@ -433,6 +446,7 @@ async function updateDropdownPosition() { async function openDropdown() { if (props.disabled || isOpen.value) return + if (!hasMinimumSearchLength.value) return isOpen.value = true emit('open') @@ -622,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() } @@ -689,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 b14a44429f..8f1c52fe96 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..06f5a21261 --- /dev/null +++ b/packages/ui/src/components/servers/access/AccessTable.vue @@ -0,0 +1,572 @@ + + + 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..072a24345f --- /dev/null +++ b/packages/ui/src/components/servers/access/AuditLogTable.vue @@ -0,0 +1,802 @@ + + + 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..79fb58dc75 --- /dev/null +++ b/packages/ui/src/components/servers/access/GrantAccessModal.vue @@ -0,0 +1,260 @@ + + + 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 new file mode 100644 index 0000000000..8f871d4e48 --- /dev/null +++ b/packages/ui/src/components/servers/access/index.ts @@ -0,0 +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 new file mode 100644 index 0000000000..d4acd06bd1 --- /dev/null +++ b/packages/ui/src/components/servers/access/types.ts @@ -0,0 +1,71 @@ +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 + inviteResendAvailableAt?: 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 + 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'] + +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 +} + +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..cb9b1c4c1c 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..db0c497816 --- /dev/null +++ b/packages/ui/src/layouts/wrapped/hosting/manage/access.vue @@ -0,0 +1,595 @@ + + + diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue index a5bf18e625..575e5e536b 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,52 @@ 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', + avatarUrl: 'https://github.com/Prospector.png', +} + +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 +700,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..793a64ffe5 100644 --- a/packages/ui/src/locales/en-US/index.json +++ b/packages/ui/src/locales/en-US/index.json @@ -2924,9 +2924,252 @@ "search.server_content_type.vanilla": { "defaultMessage": "Vanilla" }, + "servers.access-page.activity-log-title": { + "defaultMessage": "Activity log" + }, + "servers.access-page.invite-friends": { + "defaultMessage": "Add user" + }, + "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": "All" + }, + "servers.access-page.role-filter.selected": { + "defaultMessage": "Role: {role}" + }, + "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": "Limited" + }, + "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": "Limited" + }, + "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.action.resend-invite-cooldown": { + "defaultMessage": "Resend in {seconds}s" + }, + "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-prefix.member-invited": { + "defaultMessage": "Invited user:" + }, + "servers.audit-log.action-prefix.member-removed": { + "defaultMessage": "Removed user:" + }, + "servers.audit-log.action-prefix.mod-installed": { + "defaultMessage": "Installed mod:" + }, + "servers.audit-log.action-prefix.modpack-installed": { + "defaultMessage": "Installed modpack:" + }, + "servers.audit-log.action-prefix.role-changed": { + "defaultMessage": "Changed role:" + }, + "servers.audit-log.action-suffix.member-invited-role": { + "defaultMessage": "as {role}" + }, + "servers.audit-log.action-suffix.role-changed-role": { + "defaultMessage": "to {role}" + }, + "servers.audit-log.action.member-invited": { + "defaultMessage": "Invited user: {target}{role}" + }, + "servers.audit-log.action.member-removed": { + "defaultMessage": "Removed user: {target}" + }, + "servers.audit-log.action.mod-installed": { + "defaultMessage": "Installed mod: {name}{version}" + }, + "servers.audit-log.action.modpack-installed": { + "defaultMessage": "Installed modpack: {name}{version}" + }, + "servers.audit-log.action.role-changed": { + "defaultMessage": "Changed role: {target}{role}" + }, + "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.role.editor": { + "defaultMessage": "Editor" + }, + "servers.audit-log.role.owner": { + "defaultMessage": "Owner" + }, + "servers.audit-log.role.viewer": { + "defaultMessage": "Limited" + }, + "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 +3326,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 user" + }, + "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": "Limited" + }, + "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": "No matching users found." + }, + "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 +3467,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 +3521,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 +3572,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 +3596,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" }, @@ -3299,6 +3611,30 @@ "servers.medal-listing.using-project-label": { "defaultMessage": "Using {projectTitle}" }, + "servers.remove-access-modal.admonition-body": { + "defaultMessage": "{username} will no longer be able to manage or view this server. You can add them again later." + }, + "servers.remove-access-modal.admonition-header": { + "defaultMessage": "Removal warning" + }, + "servers.remove-access-modal.cancel-admonition-body": { + "defaultMessage": "{username} will need a new invitation before they can join this server." + }, + "servers.remove-access-modal.cancel-admonition-header": { + "defaultMessage": "Cancellation warning" + }, + "servers.remove-access-modal.cancel-button": { + "defaultMessage": "Cancel invite" + }, + "servers.remove-access-modal.cancel-header": { + "defaultMessage": "Cancel invite" + }, + "servers.remove-access-modal.header": { + "defaultMessage": "Remove user" + }, + "servers.remove-access-modal.remove-button": { + "defaultMessage": "Remove user" + }, "servers.notice.dismiss": { "defaultMessage": "Dismiss" }, diff --git a/packages/ui/src/stories/base/Combobox.stories.ts b/packages/ui/src/stories/base/Combobox.stories.ts index 63dcd80fdd..eb3f73a102 100644 --- a/packages/ui/src/stories/base/Combobox.stories.ts +++ b/packages/ui/src/stories/base/Combobox.stories.ts @@ -114,6 +114,21 @@ export const SearchableNoFilter: Story = { }, } +export const SearchableMinimumLength: Story = { + args: { + options: [ + { value: 'fetch', label: 'Fetch' }, + { value: 'emma', label: 'Emma' }, + { value: 'boris', label: 'Boris' }, + { value: 'coolbot', label: 'Coolbot' }, + ], + searchable: true, + searchPlaceholder: 'Enter a username or email', + minSearchLengthToOpen: 2, + noOptionsMessage: 'No matching users found.', + }, +} + export const SearchableModpacks: Story = { args: { options: [ 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..875999d924 --- /dev/null +++ b/packages/ui/src/stories/servers/AccessTable.stories.ts @@ -0,0 +1,147 @@ +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: 'Limited', + 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 PendingInviteCooldown: Story = { + args: { + members: [ + { + ...members[2], + inviteResendAvailableAt: new Date(Date.now() + 45 * 1000).toISOString(), + }, + ], + 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..d0fd7ca9b8 --- /dev/null +++ b/packages/ui/src/stories/servers/AuditLogTable.stories.ts @@ -0,0 +1,185 @@ +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', + href: '/mod/create-aeronautics', + version: '1.20.1-0.6.0', + }, + 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, + world: null, + action: { type: 'role_changed', target: 'Geometrically', role: 'viewer' }, + timestamp: new Date(Date.now() - 7 * 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..7a003b470a --- /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 lastAddedUser = ref('') + const suggestions = [ + { id: 'fetch', username: 'Fetch' }, + { id: 'emma', username: 'Emma' }, + ] + function handleGrant(payload: GrantServerAccessPayload) { + lastAddedUser.value = `${payload.target} as ${payload.role}` + } + return { modalRef, suggestions, lastAddedUser, handleGrant } + }, + template: /* html */ ` +
+ + + +

Last added: {{ lastAddedUser }}

+ +
+ `, + }), +} 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

+ +
+ `, + }), +} 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,