+
{{ 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/JoinedButtons.vue b/packages/ui/src/components/base/JoinedButtons.vue
index 2c56d50414..8aab2dc96d 100644
--- a/packages/ui/src/components/base/JoinedButtons.vue
+++ b/packages/ui/src/components/base/JoinedButtons.vue
@@ -2,6 +2,7 @@
-
-
+
{{ formatMessage(commonMessages.retryButton) }}
diff --git a/packages/ui/src/components/servers/admonitions/FileOperationAdmonition.vue b/packages/ui/src/components/servers/admonitions/FileOperationAdmonition.vue
index 3c6e806cdb..7f9f7d8915 100644
--- a/packages/ui/src/components/servers/admonitions/FileOperationAdmonition.vue
+++ b/packages/ui/src/components/servers/admonitions/FileOperationAdmonition.vue
@@ -25,7 +25,13 @@
-
+
{{ formatMessage(commonMessages.cancelButton) }}
@@ -41,6 +47,7 @@ import { computed } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
+import { useServerPermissions } from '#ui/composables/server-permissions'
import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
import { injectModrinthServerContext } from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
@@ -54,6 +61,7 @@ const props = defineProps<{
const { formatMessage } = useVIntl()
const ctx = injectModrinthServerContext()
+const { canWriteFiles, permissionDeniedMessage } = useServerPermissions()
const messages = defineMessages({
extracting: {
@@ -96,4 +104,9 @@ const title = computed(() => {
}
return formatMessage(messages.extracting, { source: sourceName.value })
})
+
+function cancelOperation() {
+ if (!canWriteFiles.value || !props.op.id) return
+ ctx.dismissOperation(props.op.id, 'cancel')
+}
diff --git a/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue b/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue
index e976f60685..279d8ccbee 100644
--- a/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue
+++ b/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue
@@ -13,6 +13,7 @@ import InstallingBanner, {
} from '#ui/components/servers/InstallingBanner.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
+import { useServerPermissions } from '#ui/composables/server-permissions'
import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
@@ -34,6 +35,7 @@ const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const ctx = injectModrinthServerContext()
const route = useRoute()
+const { canSetup, canManageBackups, permissionDeniedMessage } = useServerPermissions()
const { activeOperations, backups, progressFor, invalidate } = useServerBackupsQueue(
computed(() => ctx.serverId),
@@ -307,6 +309,7 @@ async function onBackupDismiss(item: BackupAdmonitionEntry) {
}
async function onBackupCancel(item: BackupAdmonitionEntry) {
+ if (!canManageBackups.value) return
if (cancellingIds.has(item.key)) return
cancellingIds.add(item.key)
try {
@@ -319,6 +322,7 @@ async function onBackupCancel(item: BackupAdmonitionEntry) {
}
async function onBackupRetry(item: BackupAdmonitionEntry) {
+ if (!canManageBackups.value) return
await client.archon.backups_queue_v1.retry(ctx.serverId, ctx.worldId.value!, item.backupId)
dismissedIds.add(item.key)
await invalidate()
@@ -368,6 +372,8 @@ function onContentErrorDismiss() {
:progress="syncProgress"
:content-error="contentError"
:dismissible="dismissible && !!contentError"
+ :retry-disabled="!canSetup"
+ :retry-disabled-tooltip="permissionDeniedMessage"
@dismiss="onContentErrorDismiss"
@retry="emit('content-retry')"
>
@@ -387,6 +393,8 @@ function onContentErrorDismiss() {
:item="item.entry"
:dismissible="dismissible"
:cancelling="cancellingIds.has(item.entry.key)"
+ :can-manage-backups="canManageBackups"
+ :permission-denied-message="permissionDeniedMessage"
@dismiss="onBackupDismiss(item.entry)"
@cancel="onBackupCancel(item.entry)"
@retry="onBackupRetry(item.entry)"
diff --git a/packages/ui/src/components/servers/backups/BackupCreateModal.vue b/packages/ui/src/components/servers/backups/BackupCreateModal.vue
index bd9e53c4a1..c1bfbd3521 100644
--- a/packages/ui/src/components/servers/backups/BackupCreateModal.vue
+++ b/packages/ui/src/components/servers/backups/BackupCreateModal.vue
@@ -53,7 +53,11 @@
-
+
Create backup
@@ -74,18 +78,30 @@ import {
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
+import { useVIntl } from '../../../composables/i18n'
+import { commonMessages } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue'
import StyledInput from '../../base/StyledInput.vue'
import NewModal from '../../modal/NewModal.vue'
const { addNotification } = injectNotificationManager()
+const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
-const props = defineProps<{
- backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
-}>()
+const props = withDefaults(
+ defineProps<{
+ backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
+ canCreate?: boolean
+ permissionDeniedMessage?: string
+ }>(),
+ {
+ backups: undefined,
+ canCreate: true,
+ permissionDeniedMessage: undefined,
+ },
+)
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
@@ -109,6 +125,14 @@ const nameExists = computed(() => {
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
)
})
+const createDisabled = computed(
+ () => createMutation.isPending.value || nameExists.value || !props.canCreate,
+)
+const createDisabledTooltip = computed(() =>
+ props.canCreate
+ ? undefined
+ : (props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction)),
+)
const focusInput = () => {
nextTick(() => {
@@ -129,6 +153,7 @@ const hideModal = () => {
}
const createBackup = () => {
+ if (!props.canCreate) return
const name = trimmedName.value || `Backup #${newBackupAmount.value}`
isRateLimited.value = false
diff --git a/packages/ui/src/components/servers/backups/BackupDeleteModal.vue b/packages/ui/src/components/servers/backups/BackupDeleteModal.vue
index fcbe9bc446..d00e857aad 100644
--- a/packages/ui/src/components/servers/backups/BackupDeleteModal.vue
+++ b/packages/ui/src/components/servers/backups/BackupDeleteModal.vue
@@ -67,7 +67,11 @@
-
+
{{ formatMessage(messages.confirm, { count }) }}
@@ -92,6 +96,17 @@ import BackupItem from './BackupItem.vue'
const { formatMessage } = useVIntl()
+const props = withDefaults(
+ defineProps<{
+ canDelete?: boolean
+ permissionDeniedMessage?: string
+ }>(),
+ {
+ canDelete: true,
+ permissionDeniedMessage: undefined,
+ },
+)
+
const emit = defineEmits<{
(e: 'delete', backup: Archon.BackupsQueue.v1.BackupQueueBackup | undefined): void
(e: 'bulk-delete', backups: Archon.BackupsQueue.v1.BackupQueueBackup[]): void
@@ -133,6 +148,11 @@ const count = computed(() => (isBulk.value ? bulkBackups.value.length : 1))
const displayBackups = computed(() =>
isBulk.value ? bulkBackups.value : singleBackup.value ? [singleBackup.value] : [],
)
+const deleteDisabledTooltip = computed(() =>
+ props.canDelete
+ ? undefined
+ : (props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction)),
+)
function show(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
singleBackup.value = backup
@@ -149,6 +169,7 @@ function showBulk(backups: Archon.BackupsQueue.v1.BackupQueueBackup[]) {
}
function confirmDelete() {
+ if (!props.canDelete) return
modal.value?.hide()
if (isBulk.value) {
emit('bulk-delete', bulkBackups.value)
diff --git a/packages/ui/src/components/servers/backups/BackupItem.vue b/packages/ui/src/components/servers/backups/BackupItem.vue
index 15b29d0b67..a3aec2057a 100644
--- a/packages/ui/src/components/servers/backups/BackupItem.vue
+++ b/packages/ui/src/components/servers/backups/BackupItem.vue
@@ -38,6 +38,8 @@ const props = withDefaults(
showCopyIdAction?: boolean
showDebugInfo?: boolean
restoreDisabled?: string
+ writeDisabled?: boolean
+ writeDisabledTooltip?: string
selected?: boolean
}>(),
{
@@ -47,6 +49,8 @@ const props = withDefaults(
showCopyIdAction: false,
showDebugInfo: false,
restoreDisabled: undefined,
+ writeDisabled: false,
+ writeDisabledTooltip: undefined,
selected: false,
},
)
@@ -81,13 +85,20 @@ const overflowMenuOptions = computed(() => {
disabled: !props.kyrosUrl || !props.jwt,
})
- options.push({ id: 'rename', action: () => emit('rename') })
+ options.push({
+ id: 'rename',
+ action: () => emit('rename'),
+ disabled: props.writeDisabled,
+ tooltip: props.writeDisabled ? props.writeDisabledTooltip : undefined,
+ })
options.push({ divider: true })
options.push({
id: 'delete',
color: 'red',
action: () => emit('delete'),
+ disabled: props.writeDisabled,
+ tooltip: props.writeDisabled ? props.writeDisabledTooltip : undefined,
})
return options
diff --git a/packages/ui/src/components/servers/backups/BackupRenameModal.vue b/packages/ui/src/components/servers/backups/BackupRenameModal.vue
index eaed166ea1..f44cf5ef1e 100644
--- a/packages/ui/src/components/servers/backups/BackupRenameModal.vue
+++ b/packages/ui/src/components/servers/backups/BackupRenameModal.vue
@@ -29,7 +29,11 @@
-
+
Renaming...
@@ -56,18 +60,30 @@ import {
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
+import { useVIntl } from '../../../composables/i18n'
+import { commonMessages } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue'
import StyledInput from '../../base/StyledInput.vue'
import NewModal from '../../modal/NewModal.vue'
const { addNotification } = injectNotificationManager()
+const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
-const props = defineProps<{
- backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
-}>()
+const props = withDefaults(
+ defineProps<{
+ backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
+ canRename?: boolean
+ permissionDeniedMessage?: string
+ }>(),
+ {
+ backups: undefined,
+ canRename: true,
+ permissionDeniedMessage: undefined,
+ },
+)
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
@@ -99,6 +115,14 @@ const nameExists = computed(() => {
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
)
})
+const renameDisabled = computed(
+ () => renameMutation.isPending.value || nameExists.value || !props.canRename,
+)
+const renameDisabledTooltip = computed(() =>
+ props.canRename
+ ? undefined
+ : (props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction)),
+)
const backupNumber = computed(
() => (props.backups?.findIndex((b) => b.id === currentBackup.value?.id) ?? 0) + 1,
@@ -124,6 +148,7 @@ function hide() {
}
const renameBackup = () => {
+ if (!props.canRename) return
if (!currentBackup.value) {
addNotification({
type: 'error',
diff --git a/packages/ui/src/components/servers/backups/BackupRestoreModal.vue b/packages/ui/src/components/servers/backups/BackupRestoreModal.vue
index 975c27f654..17732e4cf1 100644
--- a/packages/ui/src/components/servers/backups/BackupRestoreModal.vue
+++ b/packages/ui/src/components/servers/backups/BackupRestoreModal.vue
@@ -24,7 +24,11 @@
-
+
{{ isRestoring ? 'Restoring...' : 'Restore backup' }}
@@ -39,23 +43,37 @@
import type { Archon } from '@modrinth/api-client'
import { RotateCounterClockwiseIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
-import { ref } from 'vue'
+import { computed, ref } from 'vue'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
+import { useVIntl } from '../../../composables/i18n'
+import { commonMessages } from '../../../utils'
import Admonition from '../../base/Admonition.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import NewModal from '../../modal/NewModal.vue'
import BackupItem from './BackupItem.vue'
const { addNotification } = injectNotificationManager()
+const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
+const props = withDefaults(
+ defineProps<{
+ canRestore?: boolean
+ permissionDeniedMessage?: string
+ }>(),
+ {
+ canRestore: true,
+ permissionDeniedMessage: undefined,
+ },
+)
+
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
function safetyBackupName(backupName: string) {
@@ -72,6 +90,14 @@ const restoreMutation = useMutation({
const modal = ref>()
const currentBackup = ref(null)
const isRestoring = ref(false)
+const restoreDisabled = computed(
+ () => isRestoring.value || ctx.isServerRunning.value || !props.canRestore,
+)
+const restoreDisabledTooltip = computed(() =>
+ props.canRestore
+ ? undefined
+ : (props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction)),
+)
function show(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
currentBackup.value = backup
@@ -79,7 +105,7 @@ function show(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
}
const restoreBackup = () => {
- if (!currentBackup.value || isRestoring.value) {
+ if (!props.canRestore || !currentBackup.value || isRestoring.value) {
if (!currentBackup.value) {
addNotification({
type: 'error',
diff --git a/packages/ui/src/components/servers/edit-server-icon/EditServerIcon.vue b/packages/ui/src/components/servers/edit-server-icon/EditServerIcon.vue
index e3f2ab7fe1..936e415cca 100644
--- a/packages/ui/src/components/servers/edit-server-icon/EditServerIcon.vue
+++ b/packages/ui/src/components/servers/edit-server-icon/EditServerIcon.vue
@@ -3,17 +3,21 @@
Icon
@@ -47,19 +51,39 @@ import { computed, ref } from 'vue'
import { OverflowMenu, ServerIcon } from '#ui/components'
import { useServerImage } from '#ui/composables'
+import { useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
+import { commonMessages } from '#ui/utils/common-messages'
+
+const props = withDefaults(
+ defineProps<{
+ canEdit?: boolean
+ permissionDeniedMessage?: string
+ }>(),
+ {
+ canEdit: true,
+ permissionDeniedMessage: undefined,
+ },
+)
const { addNotification } = injectNotificationManager()
+const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const { serverId, server } = injectModrinthServerContext()
const queryClient = useQueryClient()
const isUploadingIcon = ref(false)
const isSyncingIcon = ref(false)
const isIconActionLoading = computed(() => isUploadingIcon.value || isSyncingIcon.value)
+const isIconActionDisabled = computed(() => isIconActionLoading.value || !props.canEdit)
+const editIconTooltip = computed(() =>
+ props.canEdit
+ ? 'Edit icon'
+ : (props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction)),
+)
const {
image: displayIcon,
@@ -84,7 +108,7 @@ function isNotFound(error: unknown): boolean {
}
const uploadFile = async (e: Event) => {
- if (isIconActionLoading.value) return
+ if (isIconActionDisabled.value) return
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) {
@@ -194,7 +218,7 @@ const uploadFile = async (e: Event) => {
}
const resetIcon = async () => {
- if (isIconActionLoading.value) return
+ if (isIconActionDisabled.value) return
isSyncingIcon.value = true
try {
@@ -234,7 +258,7 @@ const resetIcon = async () => {
}
const triggerFileInput = () => {
- if (isIconActionLoading.value) return
+ if (isIconActionDisabled.value) return
const input = document.createElement('input')
input.type = 'file'
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..b357dfc933 100644
--- a/packages/ui/src/components/servers/marketing/MedalServerListing.vue
+++ b/packages/ui/src/components/servers/marketing/MedalServerListing.vue
@@ -36,13 +36,29 @@
-
+
{{ 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/components/servers/server-header/PanelServerActionButton.vue b/packages/ui/src/components/servers/server-header/PanelServerActionButton.vue
index fd6cd6930d..52d47effe2 100644
--- a/packages/ui/src/components/servers/server-header/PanelServerActionButton.vue
+++ b/packages/ui/src/components/servers/server-header/PanelServerActionButton.vue
@@ -21,6 +21,8 @@
:actions="stopSplitActions"
:primary-disabled="!canTakeAction"
:dropdown-disabled="!canKill"
+ :primary-tooltip="busyTooltip"
+ :dropdown-tooltip="busyTooltip"
>
@@ -37,6 +39,7 @@
:primary-disabled="true"
:dropdown-disabled="!canKill"
:primary-muted="true"
+ :dropdown-tooltip="busyTooltip"
>
diff --git a/packages/ui/src/components/servers/server-header/use-server-power-action.ts b/packages/ui/src/components/servers/server-header/use-server-power-action.ts
index f112c8959e..e0f82d00b4 100644
--- a/packages/ui/src/components/servers/server-header/use-server-power-action.ts
+++ b/packages/ui/src/components/servers/server-header/use-server-power-action.ts
@@ -1,6 +1,7 @@
import { computed, type Ref } from 'vue'
import { useVIntl } from '#ui/composables/i18n'
+import { useServerPermissions } from '#ui/composables/server-permissions'
import {
injectModrinthClient,
injectModrinthServerContext,
@@ -14,6 +15,7 @@ export function useServerPowerAction(options?: { disabled?: Ref }) {
const client = injectModrinthClient()
const { serverId, server, powerState, busyReasons } = injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
+ const { canUsePowerActions, permissionDeniedMessage } = useServerPermissions()
const isInstalling = computed(() => server.value.status === 'installing')
const isRunning = computed(() => powerState.value === 'running')
@@ -24,20 +26,23 @@ export function useServerPowerAction(options?: { disabled?: Ref }) {
const showStopSplit = computed(() => isRunning.value || isStarting.value || isStopping.value)
const showRestartButton = computed(() => isRunning.value || isStarting.value)
- const isBlockedByPropsOrBusy = computed(
- () => Boolean(options?.disabled?.value) || busyReasons.value.length > 0,
+ const isBlockedByPropsBusyOrPermission = computed(
+ () =>
+ !canUsePowerActions.value || Boolean(options?.disabled?.value) || busyReasons.value.length > 0,
)
const busyTooltip = computed(() => {
+ if (!canUsePowerActions.value) return permissionDeniedMessage.value
if (isStarting.value) return 'Your server is starting'
return busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined
})
- const canTakeAction = computed(() => !isTransitioning.value && !isBlockedByPropsOrBusy.value)
+ const canTakeAction = computed(() => !isTransitioning.value && !isBlockedByPropsBusyOrPermission.value)
const canKill = computed(
() =>
- !isBlockedByPropsOrBusy.value && (isStopping.value || isRunning.value || isStarting.value),
+ !isBlockedByPropsBusyOrPermission.value &&
+ (isStopping.value || isRunning.value || isStarting.value),
)
const primaryActionText = computed(() => {
diff --git a/packages/ui/src/composables/index.ts b/packages/ui/src/composables/index.ts
index 404b157361..b9424efe0b 100644
--- a/packages/ui/src/composables/index.ts
+++ b/packages/ui/src/composables/index.ts
@@ -12,6 +12,7 @@ export * from './server-backup'
export * from './server-backups-queue'
export * from './server-console'
export * from './server-manage-core-runtime'
+export * from './server-permissions'
export * from './sticky-observer'
export * from './terminal'
export * from './use-loading-bar-token'
diff --git a/packages/ui/src/composables/server-manage-core-runtime.ts b/packages/ui/src/composables/server-manage-core-runtime.ts
index 4d6665ac40..d0ce4c0eb9 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'
@@ -26,6 +25,7 @@ type UseServerManageCoreRuntimeOptions = {
serverId: ReadableRef
worldId: ReadableRef
server: ReadableRef
+ serverFull?: ReadableRef
isSyncingContent: ReadableRef
extraBusyReasons?: ComputedRef
setDisconnectedOnAuthIncorrect?: boolean
@@ -35,7 +35,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 +91,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 +141,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)
@@ -384,6 +384,10 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
}
fsAuth.value = await client.archon.servers_v0.getFilesystemAuth(options.serverId.value)
}
+ const currentUserPermissions = computed(
+ () => options.server.value?.current_user_permissions ?? 0,
+ )
+ const serverFull = computed(() => options.serverFull?.value ?? null)
provideModrinthServerContext({
get serverId() {
@@ -391,6 +395,8 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
},
worldId: options.worldId as Ref,
server: options.server as Ref,
+ serverFull,
+ currentUserPermissions,
isConnected,
isWsAuthIncorrect,
powerState: serverPowerState,
diff --git a/packages/ui/src/composables/server-permissions.ts b/packages/ui/src/composables/server-permissions.ts
new file mode 100644
index 0000000000..4d21b43113
--- /dev/null
+++ b/packages/ui/src/composables/server-permissions.ts
@@ -0,0 +1,80 @@
+import { Archon } from '@modrinth/api-client'
+import { computed } from 'vue'
+
+import { useVIntl } from '#ui/composables/i18n'
+import { injectModrinthServerContext } from '#ui/providers'
+import { commonMessages } from '#ui/utils/common-messages'
+
+const UserScope = Archon.ServerUsers.v1.UserScope
+
+export type ServerPermissionName = keyof typeof UserScope
+
+export const serverPermissionBits = {
+ NONE: 0,
+ SERVER_ADMIN: 1 << 0,
+ BASE_READ: 1 << 1,
+ POWER_ACTIONS: 1 << 2,
+ FILES_WRITE: 1 << 3,
+ SETUP: 1 << 4,
+ BACKUPS: 1 << 5,
+ ADVANCED: 1 << 6,
+ RESET_SERVER: 1 << 7,
+ MANAGE_USERS: 1 << 8,
+ SUPPORT_AGENT: 1 << 9,
+ INFRA_MANAGER: 1 << 10,
+ INFRA_MANAGER_READ: 1 << 11,
+ INFRA_SERVERS_XFER: 1 << 12,
+} as const satisfies Record
+
+function hasPermissionBit(
+ permissions: Archon.Servers.v0.UserScope,
+ scope: ServerPermissionName,
+) {
+ const permission = serverPermissionBits[scope]
+ return permission === 0 || (permissions & permission) === permission
+}
+
+export function hasServerPermission(
+ permissions: Archon.Servers.v0.UserScope,
+ scope: ServerPermissionName,
+) {
+ if (scope !== 'NONE' && scope !== 'SERVER_ADMIN' && hasPermissionBit(permissions, 'SERVER_ADMIN')) {
+ return true
+ }
+ return hasPermissionBit(permissions, scope)
+}
+
+export function useServerPermissions() {
+ const { formatMessage } = useVIntl()
+ const { currentUserPermissions } = injectModrinthServerContext()
+
+ const hasCurrentUserPermission = (scope: ServerPermissionName) =>
+ hasServerPermission(currentUserPermissions.value, scope)
+
+ const permissionDeniedMessage = computed(() => formatMessage(commonMessages.noPermissionAction))
+
+ const canUsePowerActions = computed(() => hasCurrentUserPermission('POWER_ACTIONS'))
+ const canWriteFiles = computed(() => hasCurrentUserPermission('FILES_WRITE'))
+ const canSetup = computed(() => hasCurrentUserPermission('SETUP'))
+ const canManageBackups = computed(() => hasCurrentUserPermission('BACKUPS'))
+ const canUseAdvancedSettings = computed(() => hasCurrentUserPermission('ADVANCED'))
+ const canResetServer = computed(() => hasCurrentUserPermission('RESET_SERVER'))
+ const canManageUsers = computed(() => hasCurrentUserPermission('MANAGE_USERS'))
+
+ const permissionTooltip = (allowed: boolean) =>
+ allowed ? undefined : permissionDeniedMessage.value
+
+ return {
+ currentUserPermissions,
+ permissionDeniedMessage,
+ hasCurrentUserPermission,
+ canUsePowerActions,
+ canWriteFiles,
+ canSetup,
+ canManageBackups,
+ canUseAdvancedSettings,
+ canResetServer,
+ canManageUsers,
+ permissionTooltip,
+ }
+}
diff --git a/packages/ui/src/layouts/shared/console/components/ConsoleActionButtons.vue b/packages/ui/src/layouts/shared/console/components/ConsoleActionButtons.vue
index 8c4daf10d3..a720d5fb54 100644
--- a/packages/ui/src/layouts/shared/console/components/ConsoleActionButtons.vue
+++ b/packages/ui/src/layouts/shared/console/components/ConsoleActionButtons.vue
@@ -1,7 +1,11 @@
-
+
Clear
@@ -56,6 +60,8 @@ defineProps<{
shareDisabledTooltip?: string
sharing?: boolean
fullscreen?: boolean
+ clearDisabled?: boolean
+ clearDisabledTooltip?: string
showDelete?: boolean
deleteDisabled?: boolean
deleteDisabledTooltip?: string
diff --git a/packages/ui/src/layouts/shared/console/layout.vue b/packages/ui/src/layouts/shared/console/layout.vue
index ec28059e57..73b98550a8 100644
--- a/packages/ui/src/layouts/shared/console/layout.vue
+++ b/packages/ui/src/layouts/shared/console/layout.vue
@@ -44,6 +44,8 @@
:share-disabled="resolvedShareDisabled"
:sharing="isSharing"
:fullscreen="isFullscreen"
+ :clear-disabled="resolvedClearDisabled"
+ :clear-disabled-tooltip="resolvedClearDisabledTooltip"
:show-delete="showDelete"
:delete-disabled="resolvedDeleteDisabled"
:delete-disabled-tooltip="ctx.deleteDisabledTooltip"
@@ -59,6 +61,8 @@
class="min-h-0 flex-1"
:show-input="resolvedShowInput"
:disable-input="resolvedInputDisabled"
+ :disable-input-tooltip="resolvedInputDisabledTooltip"
+ :disabled-input-placeholder="resolvedInputDisabledPlaceholder"
:fullscreen="isFullscreen"
:empty-state-type="ctx.emptyStateType"
:loading="resolvedLoading"
@@ -217,6 +221,11 @@ const resolvedDisableInput = computed(() => {
return isRef(v) ? v.value : v
})
+function unwrapMaybeRef(value: T | { value: T } | undefined): T | undefined {
+ if (value === undefined) return undefined
+ return isRef(value) ? value.value : value
+}
+
// needs historical log start/end flags on ws to be properly useful
const resolvedLoading = computed(() => {
const v = ctx.loading
@@ -226,6 +235,14 @@ const resolvedLoading = computed(() => {
const resolvedInputDisabled = computed(() => resolvedDisableInput.value || resolvedLoading.value)
+const resolvedInputDisabledTooltip = computed(() =>
+ resolvedDisableInput.value ? unwrapMaybeRef(ctx.disableCommandInputTooltip) : undefined,
+)
+
+const resolvedInputDisabledPlaceholder = computed(() =>
+ resolvedInputDisabledTooltip.value ? 'Command input disabled' : 'Server is not running',
+)
+
const resolvedShareDisabled = computed(() => {
const v = ctx.shareDisabled
if (!v) return false
@@ -240,6 +257,16 @@ const resolvedDeleteDisabled = computed(() => {
return isRef(v) ? v.value : v
})
+const resolvedClearDisabled = computed(() => {
+ const v = ctx.clearDisabled
+ if (!v) return false
+ return isRef(v) ? v.value : v
+})
+
+const resolvedClearDisabledTooltip = computed(() =>
+ resolvedClearDisabled.value ? unwrapMaybeRef(ctx.clearDisabledTooltip) : undefined,
+)
+
function handleTerminalReady(_terminal: Terminal) {
rewriteFiltered()
}
@@ -360,10 +387,12 @@ watch(resolvedLoading, (loading) => {
})
function handleCommand(cmd: string) {
+ if (resolvedInputDisabled.value) return
ctx.sendCommand?.(cmd)
}
function handleClear() {
+ if (resolvedClearDisabled.value) return
const term = terminalRef.value?.terminal
if (term) clearSearchHighlights(term)
terminalRef.value?.reset()
diff --git a/packages/ui/src/layouts/shared/console/providers/console-manager.ts b/packages/ui/src/layouts/shared/console/providers/console-manager.ts
index 5946c1dc4f..1d8da4b17c 100644
--- a/packages/ui/src/layouts/shared/console/providers/console-manager.ts
+++ b/packages/ui/src/layouts/shared/console/providers/console-manager.ts
@@ -14,10 +14,13 @@ export interface ConsoleManagerContext {
sendCommand?: (cmd: string) => void
showCommandInput?: boolean | Ref | ComputedRef
disableCommandInput?: boolean | Ref | ComputedRef
+ disableCommandInputTooltip?: string | Ref | ComputedRef
loading?: Ref | ComputedRef
onClear?: () => void
+ clearDisabled?: Ref | ComputedRef
+ clearDisabledTooltip?: string | Ref | ComputedRef
onDelete?: () => Promise
deleteDisabled?: Ref | ComputedRef
deleteDisabledTooltip?: string
diff --git a/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue b/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue
index 8e19e4abb4..e83e480b7e 100644
--- a/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue
+++ b/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue
@@ -48,6 +48,9 @@ interface Props {
hideSwitchVersion?: boolean
overflowOptions?: OverflowMenuOption[]
disabled?: boolean
+ disabledTooltip?: string | null
+ toggleDisabled?: boolean
+ toggleDisabledTooltip?: string | null
showCheckbox?: boolean
hideDelete?: boolean
hideActions?: boolean
@@ -66,6 +69,9 @@ const props = withDefaults(defineProps(), {
hideSwitchVersion: false,
overflowOptions: undefined,
disabled: false,
+ disabledTooltip: undefined,
+ toggleDisabled: false,
+ toggleDisabledTooltip: undefined,
showCheckbox: false,
hideDelete: false,
hideActions: false,
@@ -91,6 +97,7 @@ const versionNumberRef = ref(null)
const fileNameRef = ref(null)
const isDisabled = computed(() => props.disabled || props.installing)
+const isToggleDisabled = computed(() => isDisabled.value || props.toggleDisabled)
const clientWarningMessage = computed(() => {
switch (props.clientWarning) {
@@ -273,7 +280,11 @@ const deleteHovered = ref(false)
hover-color-fill="background"
>
@@ -286,7 +297,11 @@ const deleteHovered = ref(false)
type="transparent"
>
@@ -297,8 +312,13 @@ const deleteHovered = ref(false)
emit('update:enabled', val as boolean)"
@@ -307,11 +327,13 @@ const deleteHovered = ref(false)
(), {
contentTypeLabel: undefined,
isBusy: false,
+ busyTooltip: undefined,
isBulkOperating: false,
bulkOperation: null,
bulkProgress: 0,
@@ -154,7 +156,9 @@ const bulkProgressMessage = computed(() => {
{
- {{ formatMessage(messages.admonitionBody, { count }) }}
+ {{ formatMessage(messages.admonitionBody, { count: props.count }) }}
@@ -27,9 +29,13 @@
-
+
- {{ formatMessage(messages.updateButton, { count }) }}
+ {{ formatMessage(messages.updateButton, { count: props.count }) }}
@@ -76,10 +82,12 @@ const messages = defineMessages({
},
})
-defineProps<{
+const props = defineProps<{
count: number
server?: boolean
backupTip?: string
+ actionDisabled?: boolean
+ actionDisabledTooltip?: string
}>()
const emit = defineEmits<{
@@ -95,6 +103,7 @@ function show() {
}
function confirm() {
+ if (props.actionDisabled) return
modal.value?.hide()
emit('update')
}
diff --git a/packages/ui/src/layouts/shared/content-tab/components/modals/ConfirmDeletionModal.vue b/packages/ui/src/layouts/shared/content-tab/components/modals/ConfirmDeletionModal.vue
index 107b33c2d9..8c8658c16f 100644
--- a/packages/ui/src/layouts/shared/content-tab/components/modals/ConfirmDeletionModal.vue
+++ b/packages/ui/src/layouts/shared/content-tab/components/modals/ConfirmDeletionModal.vue
@@ -1,21 +1,21 @@
{{ formatMessage(messages.admonitionBody) }}
@@ -28,10 +28,19 @@
{{ formatMessage(commonMessages.cancelButton) }}
-
-
+
+
- {{ formatMessage(messages.deleteButton, { count, itemType }) }}
+ {{
+ formatMessage(messages.deleteButton, {
+ count: props.count,
+ itemType: props.itemType,
+ })
+ }}
@@ -73,16 +82,20 @@ const messages = defineMessages({
},
})
-withDefaults(
+const props = withDefaults(
defineProps<{
count: number
itemType: string
variant?: 'instance' | 'server'
backupTip?: string
+ actionDisabled?: boolean
+ actionDisabledTooltip?: string
}>(),
{
variant: 'instance',
backupTip: undefined,
+ actionDisabled: false,
+ actionDisabledTooltip: undefined,
},
)
@@ -99,6 +112,7 @@ function show() {
}
function confirm() {
+ if (props.actionDisabled) return
modal.value?.hide()
emit('delete')
}
diff --git a/packages/ui/src/layouts/shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue b/packages/ui/src/layouts/shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue
index 26be19f847..ffe36eda55 100644
--- a/packages/ui/src/layouts/shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue
+++ b/packages/ui/src/layouts/shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue
@@ -35,7 +35,11 @@
-
+
{{
formatMessage(messages.confirmButton, { action: downgrade ? 'downgrade' : 'update' })
@@ -62,6 +66,8 @@ import InlineBackupCreator from './InlineBackupCreator.vue'
const props = defineProps<{
downgrade?: boolean
backupTip?: string
+ actionDisabled?: boolean
+ actionDisabledTooltip?: string
}>()
const { formatMessage } = useVIntl()
@@ -106,6 +112,7 @@ function show() {
}
function handleConfirm() {
+ if (props.actionDisabled) return
modal.value?.hide()
emit('confirm')
}
diff --git a/packages/ui/src/layouts/shared/content-tab/components/modals/ConfirmUnlinkModal.vue b/packages/ui/src/layouts/shared/content-tab/components/modals/ConfirmUnlinkModal.vue
index 369a52db35..2abba19b81 100644
--- a/packages/ui/src/layouts/shared/content-tab/components/modals/ConfirmUnlinkModal.vue
+++ b/packages/ui/src/layouts/shared/content-tab/components/modals/ConfirmUnlinkModal.vue
@@ -12,7 +12,7 @@
@@ -26,9 +26,13 @@
-
+
- {{ formatMessage(server ? messages.header : messages.unlinkButton) }}
+ {{ formatMessage(props.server ? messages.header : messages.unlinkButton) }}
@@ -48,9 +52,11 @@ import { commonMessages } from '#ui/utils/common-messages'
import InlineBackupCreator from './InlineBackupCreator.vue'
-defineProps<{
+const props = defineProps<{
server?: boolean
backupTip?: string
+ actionDisabled?: boolean
+ actionDisabledTooltip?: string
}>()
const { formatMessage } = useVIntl()
@@ -88,6 +94,7 @@ function show() {
}
function confirm() {
+ if (props.actionDisabled) return
modal.value?.hide()
emit('unlink')
}
diff --git a/packages/ui/src/layouts/shared/content-tab/components/modals/ContentInstallModal.vue b/packages/ui/src/layouts/shared/content-tab/components/modals/ContentInstallModal.vue
index 946d80c76f..8e98e440c5 100644
--- a/packages/ui/src/layouts/shared/content-tab/components/modals/ContentInstallModal.vue
+++ b/packages/ui/src/layouts/shared/content-tab/components/modals/ContentInstallModal.vue
@@ -108,14 +108,14 @@
inst.name
}}
-
-
+
+
{{ formatMessage(messages.installedBadge) }}
-
-
+
+
{{
inst.installing
? formatMessage(commonMessages.installingLabel)
diff --git a/packages/ui/src/layouts/shared/content-tab/components/modals/ContentUpdaterModal.vue b/packages/ui/src/layouts/shared/content-tab/components/modals/ContentUpdaterModal.vue
index 8684c5728a..a18274b477 100644
--- a/packages/ui/src/layouts/shared/content-tab/components/modals/ContentUpdaterModal.vue
+++ b/packages/ui/src/layouts/shared/content-tab/components/modals/ContentUpdaterModal.vue
@@ -212,7 +212,10 @@
@@ -356,6 +359,8 @@ const props = withDefaults(
loading?: boolean
/** Whether changelog is being loaded for the selected version */
loadingChangelog?: boolean
+ actionDisabled?: boolean
+ actionDisabledTooltip?: string
}>(),
{
projectType: undefined,
@@ -364,6 +369,8 @@ const props = withDefaults(
header: undefined,
loading: false,
loadingChangelog: false,
+ actionDisabled: false,
+ actionDisabledTooltip: undefined,
},
)
@@ -567,6 +574,7 @@ function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
}
function handleUpdate(event: MouseEvent) {
+ if (props.actionDisabled) return
if (selectedVersion.value) {
emit('update', selectedVersion.value, event)
hide()
diff --git a/packages/ui/src/layouts/shared/content-tab/components/modals/InlineBackupCreator.vue b/packages/ui/src/layouts/shared/content-tab/components/modals/InlineBackupCreator.vue
index f95db56679..843a50c2a4 100644
--- a/packages/ui/src/layouts/shared/content-tab/components/modals/InlineBackupCreator.vue
+++ b/packages/ui/src/layouts/shared/content-tab/components/modals/InlineBackupCreator.vue
@@ -9,13 +9,17 @@
@@ -55,6 +59,7 @@ import { watch } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
+import { useServerPermissions } from '#ui/composables/server-permissions'
import { useInlineBackup } from '../../composables/use-inline-backup'
@@ -69,9 +74,17 @@ const emit = defineEmits<{
}>()
const { formatMessage } = useVIntl()
+const { canManageBackups, permissionDeniedMessage } = useServerPermissions()
const backup = useInlineBackup(() => props.backupName)
+function startBackup() {
+ if (!canManageBackups.value || backup.externalBackupInProgress.value || backup.isBackingUp.value) {
+ return
+ }
+ backup.startBackup()
+}
+
watch(
() => backup.isBackingUp.value,
(backing) => {
diff --git a/packages/ui/src/layouts/shared/content-tab/components/modals/ModpackContentModal.vue b/packages/ui/src/layouts/shared/content-tab/components/modals/ModpackContentModal.vue
index f7461df3ab..27ce53ea91 100644
--- a/packages/ui/src/layouts/shared/content-tab/components/modals/ModpackContentModal.vue
+++ b/packages/ui/src/layouts/shared/content-tab/components/modals/ModpackContentModal.vue
@@ -36,6 +36,8 @@ interface Props {
modpackName?: string
modpackIconUrl?: string
enableToggle?: boolean
+ actionDisabled?: boolean
+ actionDisabledTooltip?: string | null
getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[]
switchVersion?: (item: ContentItem) => void
}
@@ -44,6 +46,8 @@ const props = withDefaults(defineProps(), {
modpackName: undefined,
modpackIconUrl: undefined,
enableToggle: false,
+ actionDisabled: false,
+ actionDisabledTooltip: undefined,
getOverflowOptions: undefined,
switchVersion: undefined,
})
@@ -247,6 +251,8 @@ const tableItems = computed(() =>
}
: undefined,
...(props.enableToggle ? { enabled: item.enabled } : {}),
+ toggleDisabled: props.actionDisabled,
+ toggleDisabledTooltip: props.actionDisabled ? props.actionDisabledTooltip : undefined,
isClientOnly:
isClientOnlyEnvironment(item.environment) ||
!!item.pack_client_retained ||
@@ -283,17 +289,20 @@ function getTypeIcon(type: string) {
}
function handleEnabledChange(fileName: string, value: boolean) {
+ if (props.actionDisabled) return
const item = items.value.find((i) => i.file_name === fileName)
if (!item) return
emit('update:enabled', item, value)
}
function bulkEnable() {
+ if (props.actionDisabled) return
emit('bulk:enable', [...selectedItems.value])
selectedIds.value = []
}
function bulkDisable() {
+ if (props.actionDisabled) return
emit('bulk:disable', [...selectedItems.value])
selectedIds.value = []
}
@@ -544,6 +553,8 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem })
(() => {
...base,
disabled:
isChanging(base.id) ||
- ctx.isBusy.value ||
isBulkOperating.value ||
item.installing === true,
+ toggleDisabled: ctx.isBusy.value,
+ toggleDisabledTooltip: ctx.isBusy.value ? (ctx.busyMessage?.value ?? null) : null,
installing: item.installing === true,
hasUpdate: item.has_update,
isClientOnly:
@@ -317,7 +318,7 @@ function handleDeleteById(id: string, event?: MouseEvent) {
const item = ctx.items.value.find((i) => i.id === id)
if (item) {
pendingDeletionItems.value = [item]
- if (event?.shiftKey) {
+ if (event?.shiftKey && !ctx.isBusy.value) {
confirmDelete()
} else {
confirmDeletionModal.value?.show()
@@ -327,7 +328,7 @@ function handleDeleteById(id: string, event?: MouseEvent) {
function showBulkDeleteModal(event?: MouseEvent) {
pendingDeletionItems.value = [...selectedItems.value]
- if (event?.shiftKey) {
+ if (event?.shiftKey && !ctx.isBusy.value) {
confirmDelete()
} else {
confirmDeletionModal.value?.show()
@@ -335,6 +336,7 @@ function showBulkDeleteModal(event?: MouseEvent) {
}
async function confirmDelete() {
+ if (ctx.isBusy.value) return
const itemsToDelete = [...pendingDeletionItems.value]
pendingDeletionItems.value = []
if (itemsToDelete.length === 0) return
@@ -376,6 +378,7 @@ async function confirmDelete() {
}
async function handleToggleEnabledById(id: string, _value: boolean) {
+ if (ctx.isBusy.value) return
const item = ctx.items.value.find((i) => i.id === id)
if (!item) return
markChanging(id)
@@ -387,6 +390,7 @@ async function handleToggleEnabledById(id: string, _value: boolean) {
}
async function bulkEnable() {
+ if (ctx.isBusy.value) return
const items = selectedItems.value.filter((item) => !item.enabled)
if (items.length === 0) return
if (ctx.bulkEnableItems) {
@@ -407,6 +411,7 @@ async function bulkEnable() {
}
async function bulkDisable() {
+ if (ctx.isBusy.value) return
const items = selectedItems.value.filter((item) => item.enabled)
if (items.length === 0) return
if (ctx.bulkDisableItems) {
@@ -448,7 +453,7 @@ function promptUpdateAll(event?: MouseEvent) {
const items = ctx.items.value.filter((item) => item.has_update)
if (items.length === 0) return
pendingBulkUpdateItems.value = items
- if (event?.shiftKey) {
+ if (event?.shiftKey && !ctx.isBusy.value) {
confirmBulkUpdate()
} else {
confirmBulkUpdateModal.value?.show()
@@ -460,7 +465,7 @@ function promptUpdateSelected(event?: MouseEvent) {
const items = selectedItems.value.filter((item) => item.has_update)
if (items.length === 0) return
pendingBulkUpdateItems.value = items
- if (event?.shiftKey) {
+ if (event?.shiftKey && !ctx.isBusy.value) {
confirmBulkUpdate()
} else {
confirmBulkUpdateModal.value?.show()
@@ -468,6 +473,7 @@ function promptUpdateSelected(event?: MouseEvent) {
}
async function confirmBulkUpdate() {
+ if (ctx.isBusy.value) return
const items = pendingBulkUpdateItems.value
if (items.length === 0 || !hasBulkUpdateSupport.value) return
@@ -518,12 +524,8 @@ const confirmUnlinkModal = ref>()
:owner="ctx.modpack.value.owner"
:categories="ctx.modpack.value.categories"
:has-update="ctx.modpack.value.hasUpdate"
- :disabled="ctx.modpack.value.disabled || ctx.isBusy.value"
- :disabled-text="
- ctx.modpack.value.disabledText ??
- ctx.busyMessage?.value ??
- (ctx.isBusy.value ? formatMessage(messages.pleaseWait) : undefined)
- "
+ :disabled="ctx.modpack.value.disabled"
+ :disabled-text="ctx.modpack.value.disabledText"
:show-content-hint="
!!(ctx.showContentHint?.value && ctx.modpack.value && ctx.items.value.length === 0)
"
@@ -666,14 +668,18 @@ const confirmUnlinkModal = ref>()
color-fill="text"
hover-color-fill="background"
>
-
+
{{ formatMessage(messages.updateAll) }}
-
+
{{ formatMessage(commonMessages.refreshButton) }}
@@ -752,6 +758,7 @@ const confirmUnlinkModal = ref>()
:selected-items="selectedItems"
:content-type-label="ctx.contentTypeLabel.value"
:is-busy="ctx.isBusy.value"
+ :busy-tooltip="ctx.busyMessage?.value"
:is-bulk-operating="isBulkOperating"
:bulk-operation="bulkOperation"
:bulk-progress="bulkProgress"
@@ -772,7 +779,6 @@ const confirmUnlinkModal = ref>()
>
@@ -835,7 +841,6 @@ const confirmUnlinkModal = ref>()
>
@@ -851,6 +856,8 @@ const confirmUnlinkModal = ref>()
:item-type="ctx.contentTypeLabel.value"
:variant="ctx.deletionContext ?? 'instance'"
:backup-tip="pendingDeletionItems.map((i) => i.project?.title ?? i.file_name).join(', ')"
+ :action-disabled="ctx.isBusy.value"
+ :action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@delete="confirmDelete"
/>
>()
ref="confirmBulkUpdateModal"
:count="pendingBulkUpdateItems.length"
:server="ctx.deletionContext === 'server'"
+ :action-disabled="ctx.isBusy.value"
+ :action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@update="confirmBulkUpdate"
/>
>()
ref="confirmUnlinkModal"
:server="ctx.deletionContext === 'server'"
:backup-tip="ctx.modpack.value?.project.title"
+ :action-disabled="ctx.isBusy.value"
+ :action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@unlink="ctx.unlinkModpack!()"
/>
diff --git a/packages/ui/src/layouts/shared/content-tab/types.ts b/packages/ui/src/layouts/shared/content-tab/types.ts
index c909d081ad..778d6fdc4b 100644
--- a/packages/ui/src/layouts/shared/content-tab/types.ts
+++ b/packages/ui/src/layouts/shared/content-tab/types.ts
@@ -32,6 +32,9 @@ export interface ContentCardTableItem {
owner?: ContentOwner
enabled?: boolean
disabled?: boolean
+ disabledTooltip?: string | null
+ toggleDisabled?: boolean
+ toggleDisabledTooltip?: string | null
installing?: boolean
hasUpdate?: boolean
isClientOnly?: boolean
diff --git a/packages/ui/src/layouts/shared/files-tab/components/editor/EditorFindReplace.vue b/packages/ui/src/layouts/shared/files-tab/components/editor/EditorFindReplace.vue
index c460e8869a..1e28ffbbb7 100644
--- a/packages/ui/src/layouts/shared/files-tab/components/editor/EditorFindReplace.vue
+++ b/packages/ui/src/layouts/shared/files-tab/components/editor/EditorFindReplace.vue
@@ -10,6 +10,7 @@
@@ -88,6 +89,7 @@
type="search"
size="small"
autocomplete="off"
+ :disabled="props.readonly"
:placeholder="formatMessage(messages.replaceInFile)"
wrapper-class="w-44"
/>
@@ -95,7 +97,7 @@
{{ formatMessage(messages.replace) }}
@@ -104,7 +106,7 @@
{{ formatMessage(messages.replaceAll) }}
@@ -129,6 +131,7 @@ const props = defineProps<{
findMatchCount: number
currentFindMatch: number
isEditingImage: boolean
+ readonly?: boolean
}>()
const emit = defineEmits<{
@@ -193,6 +196,7 @@ const findInputRef = ref<{ focus: () => void } | null>(null)
const replaceInputRef = ref<{ focus: () => void } | null>(null)
function toggleReplace() {
+ if (props.readonly) return
isReplaceOpen.value = !isReplaceOpen.value
if (isReplaceOpen.value) {
nextTick(() => replaceInputRef.value?.focus())
@@ -204,6 +208,7 @@ function focusFindInput() {
}
function openReplace() {
+ if (props.readonly) return
isReplaceOpen.value = true
nextTick(() => replaceInputRef.value?.focus())
}
diff --git a/packages/ui/src/layouts/shared/files-tab/components/editor/FileEditor.vue b/packages/ui/src/layouts/shared/files-tab/components/editor/FileEditor.vue
index 7f8a9ebb82..cea2200a4e 100644
--- a/packages/ui/src/layouts/shared/files-tab/components/editor/FileEditor.vue
+++ b/packages/ui/src/layouts/shared/files-tab/components/editor/FileEditor.vue
@@ -8,6 +8,7 @@
v-model:is-find-open="isFindOpen"
v-model:find-query="inFileFindQuery"
:is-editing-image="isEditingImage"
+ :readonly="isEditorReadOnly"
:find-match-count="findMatchCount"
:current-find-match="currentFindMatch"
@find-next="findNext"
@@ -22,6 +23,7 @@
v-model:value="fileContent"
:lang="editorLanguage"
theme="modrinth"
+ :readonly="isEditorReadOnly"
:print-margin="false"
:style="{ height: editorHeight, fontSize: '0.875rem' }"
class="ace-modrinth rounded-[20px]"
@@ -144,6 +146,11 @@ const editorLanguage = computed(() => {
const ext = getFileExtension(props.file?.name ?? '')
return getEditorLanguage(ext)
})
+const isEditorReadOnly = computed(() => ctx.isBusy?.value ?? false)
+
+watch(isEditorReadOnly, (readOnly) => {
+ editorInstance.value?.setReadOnly(readOnly)
+})
watch(
() => props.file,
@@ -206,6 +213,7 @@ function resetState() {
function onEditorInit(editor: Ace.Editor) {
editorInstance.value = editor
+ editor.setReadOnly(isEditorReadOnly.value)
editor.commands.addCommand({
name: 'save',
@@ -223,6 +231,7 @@ function onEditorInit(editor: Ace.Editor) {
name: 'replace',
bindKey: { win: 'Ctrl-H', mac: 'Command-Option-F' },
exec: () => {
+ if (isEditorReadOnly.value) return
isFindOpen.value = true
nextTick(() => findReplaceRef.value?.openReplace())
},
@@ -231,6 +240,7 @@ function onEditorInit(editor: Ace.Editor) {
async function saveFileContent(exit: boolean = false) {
if (!props.file) return
+ if (ctx.isBusy?.value) return
try {
const normalizedPath = props.file.path.startsWith('/') ? props.file.path : `/${props.file.path}`
@@ -312,7 +322,7 @@ function closeFind() {
function replaceOne(query: string) {
const editor = editorInstance.value
- if (!editor || findMatchCount.value === 0) return
+ if (!editor || isEditorReadOnly.value || findMatchCount.value === 0) return
editor.replace(query)
nextTick(() => {
const count = countOccurrences(fileContent.value, inFileFindQuery.value)
@@ -323,7 +333,7 @@ function replaceOne(query: string) {
function replaceAllOccurrences(query: string) {
const editor = editorInstance.value
- if (!editor || findMatchCount.value === 0) return
+ if (!editor || isEditorReadOnly.value || findMatchCount.value === 0) return
editor.replaceAll(query)
nextTick(() => {
const count = countOccurrences(fileContent.value, inFileFindQuery.value)
diff --git a/packages/ui/src/layouts/shared/files-tab/components/modals/FileUploadZipUrlModal.vue b/packages/ui/src/layouts/shared/files-tab/components/modals/FileUploadZipUrlModal.vue
index 9e5b4f31fd..dde674865a 100644
--- a/packages/ui/src/layouts/shared/files-tab/components/modals/FileUploadZipUrlModal.vue
+++ b/packages/ui/src/layouts/shared/files-tab/components/modals/FileUploadZipUrlModal.vue
@@ -36,6 +36,7 @@
{{ formatMessage(messages.zipDescription) }}
@@ -118,6 +119,17 @@ const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { formatMessage } = useVIntl()
+const props = withDefaults(
+ defineProps<{
+ disabled?: boolean
+ disabledTooltip?: string
+ }>(),
+ {
+ disabled: false,
+ disabledTooltip: undefined,
+ },
+)
+
const messages = defineMessages({
cfHeader: {
id: 'files.zip-url-modal.cf-header',
@@ -239,9 +251,17 @@ const error = computed(() => {
return ''
})
+const submitDisabled = computed(
+ () => submitted.value || props.disabled || !!error.value || backupInProgress.value,
+)
+const submitTooltip = computed(() => {
+ if (props.disabled) return props.disabledTooltip
+ return error.value || undefined
+})
+
const handleSubmit = async () => {
touched.value = true
- if (error.value) return
+ if (submitDisabled.value) return
submitted.value = true
try {
@@ -270,6 +290,8 @@ const handleSubmit = async () => {
}
const show = (isCf: boolean) => {
+ if (props.disabled) return
+
cf.value = isCf
url.value = ''
submitted.value = false
diff --git a/packages/ui/src/layouts/shared/files-tab/layout.vue b/packages/ui/src/layouts/shared/files-tab/layout.vue
index 010b9ad4d8..a01de783af 100644
--- a/packages/ui/src/layouts/shared/files-tab/layout.vue
+++ b/packages/ui/src/layouts/shared/files-tab/layout.vue
@@ -3,7 +3,12 @@
-
+
-
+
{{ formatMessage(commonMessages.saveButton) }}
@@ -370,6 +379,7 @@ async function confirmDiscardChanges(): Promise {
if (!hasUnsavedChanges.value) return true
const result = await unsavedChangesModal.value?.prompt()
if (result === 'save') {
+ if (isBusy.value) return false
await fileEditorRef.value?.saveFileContent(false)
return true
}
@@ -412,10 +422,12 @@ async function handleEditorClose() {
// CRUD handlers
async function handleCreateNewItem(name: string) {
+ if (isBusy.value) return
await ctx.createItem(name, newItemType.value)
}
async function handleRenameItem(newName: string) {
+ if (isBusy.value) return
const item = selectedItem.value
if (!item) return
@@ -432,6 +444,7 @@ async function handleRenameItem(newName: string) {
}
async function handleMoveItem(destination: string) {
+ if (isBusy.value) return
const item = selectedItem.value
if (!item) return
@@ -450,6 +463,7 @@ async function handleMoveItem(destination: string) {
}
function handleDeleteItem() {
+ if (isBusy.value) return
const item = selectedItem.value
if (!item) return
@@ -513,6 +527,7 @@ async function handleExtractItem(item: { name: string; type: string; path: strin
}
async function handleExtractConfirm(path: string) {
+ if (isBusy.value) return
if (!ctx.extractFile) return
try {
await ctx.extractFile(path, true, false)
diff --git a/packages/ui/src/layouts/shared/installation-settings/composables/use-installation-form.ts b/packages/ui/src/layouts/shared/installation-settings/composables/use-installation-form.ts
index f8eaf328cf..ae0dcc78e1 100644
--- a/packages/ui/src/layouts/shared/installation-settings/composables/use-installation-form.ts
+++ b/packages/ui/src/layouts/shared/installation-settings/composables/use-installation-form.ts
@@ -84,6 +84,7 @@ export function useInstallationForm(
})
async function save() {
+ if (ctx.isBusy.value) return
isSaving.value = true
try {
const platformChanged = selectedPlatform.value !== ctx.currentPlatform.value
@@ -156,6 +157,7 @@ export function useInstallationForm(
}
async function confirmLoaderChange() {
+ if (ctx.isBusy.value) return
try {
if (ctx.disableAllContent) {
await ctx.disableAllContent()
@@ -169,6 +171,7 @@ export function useInstallationForm(
}
async function confirmAutoFix() {
+ if (ctx.isBusy.value) return
try {
if (ctx.previewSave) {
isVerifying.value = true
@@ -210,6 +213,7 @@ export function useInstallationForm(
}
async function confirmDisableConflicts() {
+ if (ctx.isBusy.value) return
try {
if (ctx.disableIncompatibleContent) {
await ctx.disableIncompatibleContent(selectedGameVersion.value)
@@ -239,6 +243,7 @@ export function useInstallationForm(
}
async function confirmSave() {
+ if (ctx.isBusy.value) return
pendingPreview.value = null
try {
await performSave()
@@ -280,6 +285,7 @@ export function useInstallationForm(
const loadingChangelog = ref(false)
async function handleChangeModpackVersion() {
+ if (ctx.isBusy.value) return
updatingModpack.value = true
loadingChangelog.value = false
@@ -350,6 +356,7 @@ export function useInstallationForm(
}
async function handleUpdaterConfirm(version: Labrinth.Versions.v2.Version) {
+ if (ctx.isBusy.value) return
try {
await ctx.onModpackVersionConfirm(version)
} finally {
diff --git a/packages/ui/src/layouts/shared/installation-settings/layout.vue b/packages/ui/src/layouts/shared/installation-settings/layout.vue
index 4dee5e96c8..fd43260e7a 100644
--- a/packages/ui/src/layouts/shared/installation-settings/layout.vue
+++ b/packages/ui/src/layouts/shared/installation-settings/layout.vue
@@ -105,6 +105,12 @@ const disabledPlatforms = computed(() => {
if (!ctx.lockPlatform || ctx.currentPlatform.value === 'vanilla') return []
return ctx.availablePlatforms.filter((p) => p !== ctx.currentPlatform.value)
})
+const platformDisabledItems = computed(() =>
+ ctx.isBusy.value ? ctx.availablePlatforms : disabledPlatforms.value,
+)
+const platformDisabledTooltip = computed(() =>
+ ctx.isBusy.value ? (ctx.busyMessage?.value ?? undefined) : formatMessage(messages.platformLockTooltip),
+)
const showModpackVersionActions = computed(() => {
const val = ctx.showModpackVersionActions
@@ -119,6 +125,7 @@ const isLocalFile = computed(() => {
})
function handleModpackUpdateRequest(version: Labrinth.Versions.v2.Version, event?: MouseEvent) {
+ if (ctx.isBusy.value) return
pendingUpdateVersion.value = version
const currentVersionId = ctx.updaterModalProps.value.currentVersionId
const currentVersion = form.updatingProjectVersions.value.find((v) => v.id === currentVersionId)
@@ -133,6 +140,7 @@ function handleModpackUpdateRequest(version: Labrinth.Versions.v2.Version, event
}
function handleModpackUpdateConfirm() {
+ if (ctx.isBusy.value) return
if (pendingUpdateVersion.value) {
form.cancelEditing()
form.handleUpdaterConfirm(pendingUpdateVersion.value)
@@ -145,16 +153,19 @@ function handleModpackUpdateCancel() {
}
function handleRepair() {
+ if (ctx.isBusy.value) return
form.cancelEditing()
ctx.repair()
}
function handleReinstall() {
+ if (ctx.isBusy.value) return
form.cancelEditing()
ctx.reinstallModpack()
}
function handleUnlink() {
+ if (ctx.isBusy.value) return
form.cancelEditing()
ctx.unlinkModpack()
}
@@ -164,6 +175,7 @@ const emit = defineEmits<{
}>()
function handleIncompatibleResetServer() {
+ if (ctx.isBusy.value) return
form.cancelPreview()
form.cancelEditing()
emit('reset-server')
@@ -379,6 +391,7 @@ const messages = defineMessages({
(e.shiftKey ? handleUnlink() : unlinkModal?.show())"
@@ -435,6 +449,7 @@ const messages = defineMessages({
@@ -537,12 +553,16 @@ const messages = defineMessages({
formatMessage(commonMessages.selectVersionPlaceholder)
"
:aria-label="formatMessage(messages.selectGameVersionAriaLabel)"
+ :disabled="ctx.isBusy.value"
+ v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
@option-hover="ctx.onGameVersionHover?.($event)"
>
@@ -587,6 +607,8 @@ const messages = defineMessages({
loader: form.formattedLoaderName.value,
})
"
+ :disabled="ctx.isBusy.value"
+ v-tooltip="ctx.isBusy.value ? ctx.busyMessage?.value : undefined"
>
@@ -662,6 +687,7 @@ const messages = defineMessages({
isLinked: ComputedRef
isBusy: Ref | ComputedRef
+ busyMessage?: Ref | ComputedRef
modpack: Ref | ComputedRef
diff --git a/packages/ui/src/layouts/shared/server-settings/pages/advanced.vue b/packages/ui/src/layouts/shared/server-settings/pages/advanced.vue
index d28cd9650a..6ae02dfc06 100644
--- a/packages/ui/src/layouts/shared/server-settings/pages/advanced.vue
+++ b/packages/ui/src/layouts/shared/server-settings/pages/advanced.vue
@@ -8,10 +8,13 @@
SFTP
Launch SFTP
@@ -22,8 +25,9 @@
Server Address
@@ -199,11 +222,13 @@
>
@@ -211,9 +236,11 @@
@@ -253,7 +280,7 @@
:is-visible="hasUnsavedChanges || isUpdating"
:server-id="serverId"
:is-updating="isUpdating || busyReasons.length > 0"
- restart
+ :restart="canUsePowerActions"
:save="
async () => {
await saveProperties()
@@ -273,6 +300,7 @@ import { computed, ref, watch } from 'vue'
import { Accordion, Admonition, AutoLink, Chips, StyledInput, Toggle } from '#ui/components'
import SaveBanner from '#ui/components/servers/SaveBanner.vue'
+import { useServerPermissions } from '#ui/composables/server-permissions'
import { injectServerSettings } from '#ui/layouts/shared/server-settings'
import {
injectModrinthClient,
@@ -284,6 +312,11 @@ const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { serverId, worldId, powerState, busyReasons } = injectModrinthServerContext()
const queryClient = useQueryClient()
+const { canUseAdvancedSettings, canUsePowerActions, permissionDeniedMessage } =
+ useServerPermissions()
+const advancedActionTooltip = computed(() =>
+ canUseAdvancedSettings.value ? undefined : permissionDeniedMessage.value,
+)
const filesTabLink = computed(
() => `/hosting/manage/${encodeURIComponent(serverId)}/files?path=/&editing=server.properties`,
)
@@ -486,7 +519,7 @@ function buildPatch(): Archon.Content.v1.PatchPropertiesFields {
return patch
}
-const { mutateAsync: saveProperties, isPending: isUpdating } = useMutation({
+const { mutateAsync: savePropertiesMutation, isPending: isUpdating } = useMutation({
mutationFn: () =>
client.archon.properties_v1.patchProperties(serverId, worldId.value!, buildPatch()),
onSuccess: async () => {
@@ -507,6 +540,11 @@ const { mutateAsync: saveProperties, isPending: isUpdating } = useMutation({
},
})
+async function saveProperties() {
+ if (!canUseAdvancedSettings.value) return
+ await savePropertiesMutation()
+}
+
function resetProperties() {
syncFormFromData()
}
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/onboarding.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/onboarding.vue
index c1ad2947f1..bcdebf6392 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/onboarding.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/onboarding.vue
@@ -49,7 +49,12 @@
-
+
{{ formatMessage(messages.setupServerButton) }}
@@ -62,6 +67,8 @@
:show-snapshot-toggle="true"
:search-modpacks="searchModpacks"
:get-project-versions="getProjectVersions"
+ :finish-disabled="!canSetup"
+ :finish-disabled-tooltip="!canSetup ? permissionDeniedMessage : undefined"
@hide="() => {}"
@browse-modpacks="onBrowseModpacks"
@create="onCreate"
@@ -77,6 +84,7 @@ import {
defineMessages,
injectModrinthClient,
injectNotificationManager,
+ useServerPermissions,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
@@ -90,6 +98,7 @@ import { injectModrinthServerContext } from '#ui/providers'
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
+const { canSetup, permissionDeniedMessage } = useServerPermissions()
const messages = defineMessages({
welcomeTitle: {
@@ -196,11 +205,16 @@ const uploadPercent = computed(() =>
totalBytes.value > 0 ? Math.round((uploadedBytes.value / totalBytes.value) * 100) : 0,
)
-const openModal = () => modalRef.value?.show()
+const openModal = () => {
+ if (!canSetup.value) return
+ modalRef.value?.show()
+}
onBeforeUnmount(() => modalRef.value?.hide())
function onBrowseModpacks() {
+ if (!canSetup.value) return
+
if (props.browseModpacks) {
props.browseModpacks({
serverId,
@@ -217,6 +231,11 @@ function onBrowseModpacks() {
}
onMounted(async () => {
+ if (!canSetup.value && route.query.resumeModal) {
+ router.replace({ query: {} })
+ return
+ }
+
if (route.query.resumeModal === 'setup-type') {
router.replace({ query: {} })
openModal()
@@ -263,6 +282,11 @@ function toApiLoader(loader: string): Archon.Content.v1.Modloader {
}
const onCreate = async (config: CreationFlowContextValue) => {
+ if (!canSetup.value) {
+ config.loading.value = false
+ return
+ }
+
// Handle mrpack file upload
if (config.setupType.value === 'modpack' && config.modpackFile.value) {
modalRef.value?.hide()
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..2dbb4e5a21
--- /dev/null
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/access.vue
@@ -0,0 +1,1203 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.inviteFriends) }}
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.activityLogTitle) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/backups.vue b/packages/ui/src/layouts/wrapped/hosting/manage/backups.vue
index a453547956..2aad6d77a6 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/backups.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/backups.vue
@@ -27,12 +27,28 @@