+
{{ 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 @@
+
+
+
+
+
+
+ {{ member.user.username }}
+
+
+
+
+
+
+ {{ formatRole(member.role) }}
+
+
+ emit('updateRole', member, role)"
+ >
+
+
+ {{ formatRole(member.role) }}
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.pendingLabel) }}
+
+
+ {{ formatRelativeTime(member.joinedAt) }}
+
+ {{ formatMessage(messages.unknownJoinedDate) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.actionsColumn) }}
+
+
+
+
+
+
+ {{ formatRole(member.role) }}
+
+
+ emit('updateRole', member, role)"
+ >
+
+
+ {{ formatRole(member.role) }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.pendingLabel) }}
+
+
+ {{ formatRelativeTime(member.joinedAt) }}
+
+ {{ formatMessage(messages.unknownJoinedDate) }}
+
+
+
+
+
+
+ {{ formatMessage(messages.memberActionsLabel, { username: member.user.username }) }}
+
+
+
+ {{ resendInviteTooltip(member) }}
+
+
+
+ {{ formatMessage(messages.cancelInvite) }}
+
+
+
+ {{ formatMessage(messages.removeUser) }}
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.userColumn) }}
+
+
+ {{ formatMessage(messages.roleColumn) }}
+
+
+ {{ formatMessage(messages.joinedColumn) }}
+
+
+ {{ formatMessage(messages.actionsColumn) }}
+
+
+
+ {{ formatMessage(messages.emptyState) }}
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ entry.actor.id === 'support'
+ ? formatMessage(messages.supportActor)
+ : entry.actor.username
+ }}
+
+
+
+
+
+
+ {{ entry.world?.name ?? '—' }}
+
+
+
+
+
+
+ {{ actionVerb(entry.action) }}:
+ {{
+ contentTypeLabel(entry.action.contentType)
+ }}
+
+
+
+ {{ entry.action.name }}
+
+
+
+ {{ formatVersionSuffix(entry.action.version) }}
+
+
+
+ {{ actionVerb(entry.action) }}:
+
+
+
+ {{ entry.action.target }}
+
+
+
+ {{ memberActionSuffix(entry.action) }}
+
+
+
+ {{ actionVerb(entry.action) }}:
+
+ {{ actionMetadata(entry.action) }}
+
+
+
+
+
+
+
+ {{ formatRelativeTime(entry.timestamp) }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.userColumn) }}
+
+
+ {{ formatMessage(messages.actionColumn) }}
+
+
+ {{ formatMessage(messages.timeColumn) }}
+
+
+
+
+
+
+
+ {{ actionVerb(entry.action) }}:
+ {{
+ contentTypeLabel(entry.action.contentType)
+ }}
+
+
+
+ {{ entry.action.name }}
+
+
+
+ {{ formatVersionSuffix(entry.action.version) }}
+
+
+
+ {{ actionVerb(entry.action) }}:
+
+
+
+ {{ entry.action.target }}
+
+
+
+ {{ memberActionSuffix(entry.action) }}
+
+
+
+ {{ actionVerb(entry.action) }}:
+
+ {{ actionMetadata(entry.action) }}
+
+
+
+
+ {{ entry.world?.name ?? '—' }}
+
+
+
+
+ {{ formatRelativeTime(entry.timestamp) }}
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.userColumn) }}
+
+
+ {{ formatMessage(messages.worldColumn) }}
+
+
+ {{ formatMessage(messages.actionColumn) }}
+
+
+ {{ formatMessage(messages.timeColumn) }}
+
+
+
+ {{ formatMessage(emptyStateMessage) }}
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
(target = option.value)"
+ >
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+ {{ formatMessage(messages.targetHelp) }}
+
+
+
+
+
+ {{ formatMessage(messages.roleLabel) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ {{
+ formatMessage(shouldCancel ? messages.cancelAdmonitionBody : messages.admonitionBody, {
+ username,
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.activityLogTitle) }}
+
+
+
+
+
+
+
+
+
+
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 */ `
+ {}"
+ @cancel-invite="removeMember"
+ @remove-member="removeMember"
+ />
+ `,
+ }),
+}
+
+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 */ `
+
+
{}"
+ @cancel-invite="removeMember"
+ @remove-member="removeMember"
+ />
+
+ `,
+ }),
+}
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,