+
{{ 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,10 @@ 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 +436,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 +444,7 @@ async function updateDropdownPosition() {
async function openDropdown() {
if (props.disabled || isOpen.value) return
+ if (!hasMinimumSearchLength.value) return
isOpen.value = true
emit('open')
@@ -622,6 +634,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 +705,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/base/Table.vue b/packages/ui/src/components/base/Table.vue
index d952a5337b..c39354d637 100644
--- a/packages/ui/src/components/base/Table.vue
+++ b/packages/ui/src/components/base/Table.vue
@@ -14,31 +14,30 @@
-
{{ column.label ?? '' }}
-
-
-
-
+
+
+
+
+ {{ column.label }}
|
diff --git a/packages/ui/src/components/servers/ServerListing.vue b/packages/ui/src/components/servers/ServerListing.vue
index 744c9f74dd..26e6f69e71 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..418ba579b3
--- /dev/null
+++ b/packages/ui/src/components/servers/access/AccessTable.vue
@@ -0,0 +1,573 @@
+
+
+
+
+
+
+ {{ 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..282ae33e1d
--- /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..489fcab349
--- /dev/null
+++ b/packages/ui/src/components/servers/access/GrantAccessModal.vue
@@ -0,0 +1,347 @@
+
+
+
+
+
+
+
+
+
+
+ {{ 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..5633d02480
--- /dev/null
+++ b/packages/ui/src/components/servers/access/RemoveAccessModal.vue
@@ -0,0 +1,268 @@
+
+
+
+
+ {{
+ formatMessage(shouldCancel ? messages.cancelWarningBody : messages.warningBody, {
+ username,
+ })
+ }}
+
+
+
+
+
+
+ {{ username }}
+
+ {{ memberStatusLabel }}
+
+
+
{{ memberSubtitle }}
+
+
+
+
+
{{
+ formatMessage(messages.whatHappensLabel)
+ }}
+
+ -
+ {{ formatMessage(effect) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/icons/LoaderIcon.vue b/packages/ui/src/components/servers/icons/LoaderIcon.vue
index 02d0ae05d2..843789c608 100644
--- a/packages/ui/src/components/servers/icons/LoaderIcon.vue
+++ b/packages/ui/src/components/servers/icons/LoaderIcon.vue
@@ -224,9 +224,10 @@
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/labels/ServerLoaderLabel.vue b/packages/ui/src/components/servers/labels/ServerLoaderLabel.vue
index d3e5ee06e6..9478a89a9e 100644
--- a/packages/ui/src/components/servers/labels/ServerLoaderLabel.vue
+++ b/packages/ui/src/components/servers/labels/ServerLoaderLabel.vue
@@ -38,6 +38,7 @@
import { computed } from 'vue'
import { injectServerSettingsModal } from '#ui/providers/server-settings-modal'
+import type { ServerLoader } from '#ui/utils/loaders'
import AutoLink from '../../base/AutoLink.vue'
import LoaderIcon from '../icons/LoaderIcon.vue'
@@ -45,7 +46,7 @@ import Separator from './Separator.vue'
defineProps<{
noSeparator?: boolean
- loader?: 'Fabric' | 'Quilt' | 'Forge' | 'NeoForge' | 'Paper' | 'Spigot' | 'Bukkit' | 'Vanilla'
+ loader?: ServerLoader
loaderVersion?: string
isLink?: boolean
}>()
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/composables/server-manage-core-runtime.ts b/packages/ui/src/composables/server-manage-core-runtime.ts
index 4d6665ac40..d803f183d7 100644
--- a/packages/ui/src/composables/server-manage-core-runtime.ts
+++ b/packages/ui/src/composables/server-manage-core-runtime.ts
@@ -4,13 +4,12 @@ import {
setNodeAuthState,
type UploadState,
} from '@modrinth/api-client'
-import type { Stats } from '@modrinth/utils'
import type { ComputedRef, Ref } from 'vue'
import { computed, ref } from 'vue'
import type { FileOperation } from '../layouts/shared/files-tab/types'
import { injectModrinthClient, provideModrinthServerContext } from '../providers'
-import type { BusyReason } from '../providers/server-context'
+import type { BusyReason, ServerStats } from '../providers/server-context'
import { defineMessage } from './i18n'
import { useModrinthServersConsole } from './server-console'
@@ -35,7 +34,7 @@ type UseServerManageCoreRuntimeOptions = {
onStateEvent?: (data: Archon.Websocket.v0.WSStateEvent) => void
}
-const createInitialStats = (): Stats => ({
+const createInitialStats = (): ServerStats => ({
current: {
cpu_percent: 0,
ram_usage_bytes: 0,
@@ -91,7 +90,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
const serverPowerState = ref('stopped')
const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>()
const isServerRunning = computed(() => serverPowerState.value === 'running')
- const stats = ref(createInitialStats())
+ const stats = ref(createInitialStats())
const uptimeSeconds = ref(0)
const fsAuth = ref<{ url: string; token: string } | null>(null)
const fsOps = ref([])
@@ -141,7 +140,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
}, 1000)
}
- const updateStats = (currentStats: Stats['current']) => {
+ const updateStats = (currentStats: ServerStats['current']) => {
if (!shouldProcessEvent()) return
if (!isConnected.value) isConnected.value = true
cpuData.value = appendGraphData(cpuData.value, currentStats.cpu_percent)
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..99ede40b0c
--- /dev/null
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/access.vue
@@ -0,0 +1,671 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.activityLogTitle) }}
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/components/ServerManageStats.vue b/packages/ui/src/layouts/wrapped/hosting/manage/components/ServerManageStats.vue
index 04e4949440..ac3dfe1ec8 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/components/ServerManageStats.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/components/ServerManageStats.vue
@@ -60,12 +60,12 @@