@@ -78,7 +127,7 @@
(),
{
showSelection: false,
@@ -229,6 +279,10 @@ function getRowRenderKey(row: T, rowIndex: number): PropertyKey {
return rowIndex
}
+function getRowClass(rowIndex: number): string {
+ return rowIndex % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'
+}
+
function isSelected(row: T): boolean {
return selectedIds.value.includes(getRowId(row))
}
diff --git a/packages/ui/src/components/base/TimeFramePicker.vue b/packages/ui/src/components/base/TimeFramePicker.vue
new file mode 100644
index 0000000000..ee243d4aa6
--- /dev/null
+++ b/packages/ui/src/components/base/TimeFramePicker.vue
@@ -0,0 +1,950 @@
+
+
+
+
+
+
+
+
+
+
+ {{ rangeLabel }}:
+ {{ formattedRange }}
+
+
+
+
+
+ {{ formatMessage(messages.emptyRange) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.lastTimeframePrefix) }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/base/index.ts b/packages/ui/src/components/base/index.ts
index 14f5382e30..bcc9ae73a1 100644
--- a/packages/ui/src/components/base/index.ts
+++ b/packages/ui/src/components/base/index.ts
@@ -83,6 +83,13 @@ export type { TabsTab, TabsValue } from './Tabs.vue'
export { default as Tabs } from './Tabs.vue'
export { default as TagItem } from './TagItem.vue'
export { default as TagTagItem } from './TagTagItem.vue'
+export type {
+ TimeFrameLastUnit,
+ TimeFrameMode,
+ TimeFramePickerSelection,
+ TimeFramePreset,
+} from './TimeFramePicker.vue'
+export { default as TimeFramePicker } from './TimeFramePicker.vue'
export { default as Timeline } from './Timeline.vue'
export { default as Toggle } from './Toggle.vue'
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'
diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/ModpackStage.vue b/packages/ui/src/components/flows/creation-flow-modal/components/ModpackStage.vue
index 5bb9697ddd..aa21a6ee9b 100644
--- a/packages/ui/src/components/flows/creation-flow-modal/components/ModpackStage.vue
+++ b/packages/ui/src/components/flows/creation-flow-modal/components/ModpackStage.vue
@@ -5,8 +5,10 @@
}}
-
-
+
{{ formatMessage(messages.browseModpacks) }}
@@ -87,6 +99,8 @@ const messages = defineMessages({
})
function proceedWithModpack() {
+ if (ctx.finishDisabled.value) return
+
debug('proceedWithModpack:', {
flowType: ctx.flowType,
modpackSelection: ctx.modpackSelection.value,
@@ -196,6 +210,8 @@ watch(
)
async function triggerFileInput() {
+ if (ctx.finishDisabled.value) return
+
const picked = await filePicker.pickModpackFile()
if (picked) {
ctx.modpackFile.value = picked.file
diff --git a/packages/ui/src/components/flows/creation-flow-modal/creation-flow-context.ts b/packages/ui/src/components/flows/creation-flow-modal/creation-flow-context.ts
index f48b090af8..9582c4df8a 100644
--- a/packages/ui/src/components/flows/creation-flow-modal/creation-flow-context.ts
+++ b/packages/ui/src/components/flows/creation-flow-modal/creation-flow-context.ts
@@ -186,6 +186,8 @@ export interface CreationFlowContextValue {
// Loading state (set when finish() is called, cleared on reset)
loading: Ref
+ finishDisabled: ComputedRef
+ finishDisabledTooltip: ComputedRef
// Backup state (set by InlineBackupCreator in reset-server flow)
isBackingUp: Ref
@@ -232,6 +234,8 @@ export interface CreationFlowOptions {
searchModpacks?: (query: string, limit?: number) => Promise
getProjectVersions?: (projectId: string) => Promise<{ id: string }[]>
getLoaderManifest?: LoaderManifestResolver
+ finishDisabled?: ComputedRef
+ finishDisabledTooltip?: ComputedRef
}
export function createCreationFlowContext(
@@ -257,6 +261,8 @@ export function createCreationFlowContext(
const searchModpacks = options.searchModpacks!
const getProjectVersions = options.getProjectVersions!
const getLoaderManifest = options.getLoaderManifest ?? null
+ const finishDisabled = options.finishDisabled ?? computed(() => false)
+ const finishDisabledTooltip = options.finishDisabledTooltip ?? computed(() => undefined)
const setupType = ref(null)
const isImportMode = ref(false)
@@ -502,6 +508,8 @@ export function createCreationFlowContext(
}
function finish() {
+ if (finishDisabled.value) return
+
debug('finish() called, state:', {
setupType: setupType.value,
selectedLoader: selectedLoader.value,
@@ -585,6 +593,8 @@ export function createCreationFlowContext(
importSearchQuery,
hardReset,
loading,
+ finishDisabled,
+ finishDisabledTooltip,
isBackingUp,
cancelBackup,
modal,
diff --git a/packages/ui/src/components/flows/creation-flow-modal/index.vue b/packages/ui/src/components/flows/creation-flow-modal/index.vue
index 6d2ab01b53..fed5929a08 100644
--- a/packages/ui/src/components/flows/creation-flow-modal/index.vue
+++ b/packages/ui/src/components/flows/creation-flow-modal/index.vue
@@ -10,7 +10,7 @@
diff --git a/packages/ui/src/components/servers/access/AuditLogEventCell.vue b/packages/ui/src/components/servers/access/AuditLogEventCell.vue
new file mode 100644
index 0000000000..153a3d5382
--- /dev/null
+++ b/packages/ui/src/components/servers/access/AuditLogEventCell.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
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..d224f0e10a
--- /dev/null
+++ b/packages/ui/src/components/servers/access/AuditLogTable.vue
@@ -0,0 +1,671 @@
+
+
+
+
+
+
+
+
+
+ {{ column.label }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.instanceTooltipTitle) }}
+
+
+ {{ formatMessage(messages.instanceTooltipDescription) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ actorName(entry) }}
+
+
+
+
+
+
+
+
+
+
+ {{ entry.world?.name ?? '—' }}
+
+
+
+
+
+ {{ formatCompactRelativeTime(entry.timestamp) }}
+
+
+
+
+
+
+
+
+
+ {{ actorName(entry) }}
+
+
+
+
+
+
+
+ {{ entry.world?.name ?? formatMessage(messages.serverScope) }}
+
+
+
+ {{ formatRelativeTime(entry.timestamp) }}
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.userColumn) }}
+
+
+ {{ formatMessage(messages.eventColumn) }}
+
+
+
+ {{ formatMessage(messages.worldColumn) }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.instanceTooltipTitle) }}
+
+
+ {{ formatMessage(messages.instanceTooltipDescription) }}
+
+
+
+
+
+
+
+ {{ 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..e85291dd01
--- /dev/null
+++ b/packages/ui/src/components/servers/access/GrantAccessModal.vue
@@ -0,0 +1,405 @@
+
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+ {{ formatMessage(messages.targetHelp) }}
+
+
+
+
+
+ {{ formatMessage(messages.roleLabel) }}
+
+
+
+
+
+
+
+ {{ role.label }}
+ {{ role.description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.cancelButton) }}
+
+
+
+
+
+ {{ formatMessage(messages.inviteButton) }}
+
+
+
+
+
+
+
+
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..bc206e3902
--- /dev/null
+++ b/packages/ui/src/components/servers/access/RemoveAccessModal.vue
@@ -0,0 +1,271 @@
+
+
+
+
+ {{
+ formatMessage(shouldCancel ? messages.cancelWarningBody : messages.warningBody, {
+ username,
+ })
+ }}
+
+
+
+
+
+
+ {{ username }}
+
+ {{ memberStatusLabel }}
+
+
+ {{ memberSubtitle }}
+
+
+
+
+ {{
+ formatMessage(messages.whatHappensLabel)
+ }}
+
+ -
+ {{ formatMessage(effect) }}
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+
+
+ {{ formatMessage(shouldCancel ? messages.cancelButton : messages.removeButton) }}
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/servers/access/events/AddonEvent.vue b/packages/ui/src/components/servers/access/events/AddonEvent.vue
new file mode 100644
index 0000000000..e0cbf619f8
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/AddonEvent.vue
@@ -0,0 +1,87 @@
+
+
+
+ {{ formatMessage(messages.deletedLabel) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/servers/access/events/BackupEvent.vue b/packages/ui/src/components/servers/access/events/BackupEvent.vue
new file mode 100644
index 0000000000..f70e84fdb6
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/BackupEvent.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+ {{ from }}
+
+
+
+
+
+
diff --git a/packages/ui/src/components/servers/access/events/BaseEvent.vue b/packages/ui/src/components/servers/access/events/BaseEvent.vue
new file mode 100644
index 0000000000..811dae60c0
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/BaseEvent.vue
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/servers/access/events/BasicStringEvent.vue b/packages/ui/src/components/servers/access/events/BasicStringEvent.vue
new file mode 100644
index 0000000000..3915755955
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/BasicStringEvent.vue
@@ -0,0 +1,80 @@
+
+
+ {{ formatMessage(message) }}
+
+
+
+
diff --git a/packages/ui/src/components/servers/access/events/ConfigEvent.vue b/packages/ui/src/components/servers/access/events/ConfigEvent.vue
new file mode 100644
index 0000000000..efa0340063
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/ConfigEvent.vue
@@ -0,0 +1,162 @@
+
+
+
+ {{ formatMessage(messages.propertiesModifiedLabel) }}
+
+ {{ propertiesLabel }}
+
+
+
+
+
+ {{ newVersion }}
+
+
+
+
+ {{ newLoaderLabel }}
+
+
+
+
+ {{ command }}
+
+
+
+
+ {{ vendor }}
+
+
+
+
+ {{ version }}
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/servers/access/events/ConsoleEvent.vue b/packages/ui/src/components/servers/access/events/ConsoleEvent.vue
new file mode 100644
index 0000000000..bea53c920b
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/ConsoleEvent.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+ {{ command }}
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/servers/access/events/EventEntityLink.vue b/packages/ui/src/components/servers/access/events/EventEntityLink.vue
new file mode 100644
index 0000000000..aa84d7e6d5
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/EventEntityLink.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+ {{ entity.label }}
+
+
+ {{ entity.secondaryLabel }}
+
+
+
+
+
diff --git a/packages/ui/src/components/servers/access/events/EventEntityList.vue b/packages/ui/src/components/servers/access/events/EventEntityList.vue
new file mode 100644
index 0000000000..c3f22ba60f
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/EventEntityList.vue
@@ -0,0 +1,135 @@
+
+
+
+
+ ,
+
+
+
+ {{ formatMessage(messages.hiddenCount, { count: hiddenCount }) }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/servers/access/events/FileEvent.vue b/packages/ui/src/components/servers/access/events/FileEvent.vue
new file mode 100644
index 0000000000..5f8ac13e62
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/FileEvent.vue
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/servers/access/events/ModpackEvent.vue b/packages/ui/src/components/servers/access/events/ModpackEvent.vue
new file mode 100644
index 0000000000..31b58296a5
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/ModpackEvent.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+ {{ versionLabel }}
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/servers/access/events/NetworkEvent.vue b/packages/ui/src/components/servers/access/events/NetworkEvent.vue
new file mode 100644
index 0000000000..15c83fe05d
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/NetworkEvent.vue
@@ -0,0 +1,35 @@
+
+
+
+
+ {{ port }}
+
+
+
+
+
+
diff --git a/packages/ui/src/components/servers/access/events/PowerEvent.vue b/packages/ui/src/components/servers/access/events/PowerEvent.vue
new file mode 100644
index 0000000000..4d0b922b26
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/PowerEvent.vue
@@ -0,0 +1,19 @@
+
+
+ {{ formatMessage(messages.powerEvent) }}
+
+
+
+
diff --git a/packages/ui/src/components/servers/access/events/ServerMetaEvent.vue b/packages/ui/src/components/servers/access/events/ServerMetaEvent.vue
new file mode 100644
index 0000000000..86d410c693
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/ServerMetaEvent.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+ {{ specsLabel }}
+
+
+
+
+
+
diff --git a/packages/ui/src/components/servers/access/events/SftpEvent.vue b/packages/ui/src/components/servers/access/events/SftpEvent.vue
new file mode 100644
index 0000000000..8a0a601bec
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/SftpEvent.vue
@@ -0,0 +1,19 @@
+
+
+ {{ formatMessage(messages.sftpLogin) }}
+
+
+
+
diff --git a/packages/ui/src/components/servers/access/events/UnknownEvent.vue b/packages/ui/src/components/servers/access/events/UnknownEvent.vue
new file mode 100644
index 0000000000..93260a33b4
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/UnknownEvent.vue
@@ -0,0 +1,35 @@
+
+
+ {{ formatMessage(messages.unknownEvent) }}
+
+ {{ rawAction }}
+
+
+
+
+
diff --git a/packages/ui/src/components/servers/access/events/UserAccessEvent.vue b/packages/ui/src/components/servers/access/events/UserAccessEvent.vue
new file mode 100644
index 0000000000..351aa512dc
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/UserAccessEvent.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/servers/access/events/index.ts b/packages/ui/src/components/servers/access/events/index.ts
new file mode 100644
index 0000000000..91dbf5f5e0
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/index.ts
@@ -0,0 +1,18 @@
+export { default as AddonEvent } from './AddonEvent.vue'
+export { default as BackupEvent } from './BackupEvent.vue'
+export { default as BaseEvent } from './BaseEvent.vue'
+export { default as BasicStringEvent } from './BasicStringEvent.vue'
+export { default as ConfigEvent } from './ConfigEvent.vue'
+export { default as ConsoleEvent } from './ConsoleEvent.vue'
+export { default as EventEntityLink } from './EventEntityLink.vue'
+export { default as EventEntityList } from './EventEntityList.vue'
+export { default as FileEvent } from './FileEvent.vue'
+export { default as ModpackEvent } from './ModpackEvent.vue'
+export { default as NetworkEvent } from './NetworkEvent.vue'
+export * from './parser'
+export { default as PowerEvent } from './PowerEvent.vue'
+export { default as ServerMetaEvent } from './ServerMetaEvent.vue'
+export { default as SftpEvent } from './SftpEvent.vue'
+export * from './types'
+export { default as UnknownEvent } from './UnknownEvent.vue'
+export { default as UserAccessEvent } from './UserAccessEvent.vue'
diff --git a/packages/ui/src/components/servers/access/events/parser.ts b/packages/ui/src/components/servers/access/events/parser.ts
new file mode 100644
index 0000000000..242f8b2713
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/parser.ts
@@ -0,0 +1,609 @@
+import type { Archon } from '@modrinth/api-client'
+import { PackageIcon } from '@modrinth/assets'
+import type { Component } from 'vue'
+
+import AddonEvent from './AddonEvent.vue'
+import BackupEvent from './BackupEvent.vue'
+import BasicStringEvent from './BasicStringEvent.vue'
+import ConfigEvent from './ConfigEvent.vue'
+import ConsoleEvent from './ConsoleEvent.vue'
+import FileEvent from './FileEvent.vue'
+import ModpackEvent from './ModpackEvent.vue'
+import NetworkEvent from './NetworkEvent.vue'
+import ServerMetaEvent from './ServerMetaEvent.vue'
+import type {
+ AuditActor,
+ AuditAddonEventItem,
+ AuditBackupEventItem,
+ AuditEventLookups,
+ AuditWorld,
+ BaseEventProps,
+ EventEntity,
+ ParsedAuditEvent,
+} from './types'
+import UnknownEvent from './UnknownEvent.vue'
+import UserAccessEvent from './UserAccessEvent.vue'
+
+const basicEvents = new Set([
+ 'server_created',
+ 'server_reallocated',
+ 'server_repaired',
+ 'server_reset',
+ 'server_started',
+ 'server_stopped',
+ 'server_restarted',
+ 'server_killed',
+ 'sftp_login',
+ 'console_cleared',
+])
+
+export function parseAuditEvent(
+ entry: Archon.Actions.v1.ActionEntry,
+ lookups: AuditEventLookups,
+): ParsedAuditEvent {
+ const action = entry.action?.action || 'unknown'
+ const metadata = entry.action?.metadata
+ const base = baseProps(entry, lookups, action)
+
+ try {
+ if (basicEvents.has(action)) {
+ return parsed(BasicStringEvent, base, {}, actionSearchParts(action))
+ }
+
+ switch (action) {
+ case 'changed_server_name': {
+ const record = metadataRecord(metadata)
+ const name = stringField(record, 'name')
+ if (!name) return unknown(base, action)
+ return parsed(
+ ServerMetaEvent,
+ base,
+ { kind: 'name', name },
+ actionSearchParts(action, name),
+ )
+ }
+ case 'changed_server_subdomain': {
+ const record = metadataRecord(metadata)
+ const subdomain = stringField(record, 'subdomain')
+ if (!subdomain) return unknown(base, action)
+ return parsed(ServerMetaEvent, base, { kind: 'subdomain', subdomain }, [
+ ...actionSearchParts(action),
+ subdomain,
+ ])
+ }
+ case 'server_plan_changed': {
+ const record = metadataRecord(metadata)
+ const newSpecs = objectField(record, 'new_specs')
+ if (!newSpecs) return unknown(base, action)
+ return parsed(ServerMetaEvent, base, { kind: 'plan', newSpecs }, [
+ ...actionSearchParts(action),
+ ...Object.values(newSpecs).map(String),
+ ])
+ }
+ case 'user_invited':
+ case 'user_permission_modified': {
+ const actionMetadata = userPermissionsActionMetadata(metadataRecord(metadata))
+ if (!actionMetadata) return unknown(base, action)
+ const kind = action === 'user_invited' ? 'invited' : 'permission_modified'
+ const targetUser = userEntity(actionMetadata.user_id, lookups.users)
+ return parsed(
+ UserAccessEvent,
+ base,
+ { kind, targetUser, permissions: actionMetadata.permissions },
+ [...actionSearchParts(action), targetUser.label, actionMetadata.permissions],
+ )
+ }
+ case 'user_invite_revoked':
+ case 'user_removed': {
+ const record = metadataRecord(metadata)
+ const userId = stringField(record, 'user_id')
+ if (!userId) return unknown(base, action)
+ const kind = action === 'user_invite_revoked' ? 'invite_revoked' : 'removed'
+ const targetUser = userEntity(userId, lookups.users)
+ return parsed(UserAccessEvent, base, { kind, targetUser }, [
+ ...actionSearchParts(action),
+ targetUser.label,
+ ])
+ }
+ case 'addon_added':
+ case 'addon_disabled':
+ case 'addon_enabled':
+ case 'addon_deleted':
+ case 'addon_updated': {
+ const addons = addonList(metadataRecord(metadata), lookups)
+ if (!addons) return unknown(base, action)
+ const kind = action.replace('addon_', '')
+ return parsed(AddonEvent, base, { kind, addons }, [
+ ...actionSearchParts(action),
+ ...addons.flatMap((addon) => [
+ addon.project.label,
+ addon.addonId,
+ addon.versionId,
+ addon.versionLabel,
+ ]),
+ ])
+ }
+ case 'addon_uploaded': {
+ const fileNames = stringArrayField(metadataRecord(metadata), 'file_names')
+ if (!fileNames) return unknown(base, action)
+ const files = fileNames.map((name) => fileEntity(name, lookups.serverId, false))
+ return parsed(AddonEvent, base, { kind: 'uploaded', fileNames: files }, [
+ ...actionSearchParts(action),
+ ...fileNames,
+ ])
+ }
+ case 'modpack_changed': {
+ const record = metadataRecord(metadata)
+ const modpack = modpackEntityFromMetadata(record, lookups)
+ const versionLabel = modpack ? null : modpackVersionLabelFromMetadata(record, lookups)
+ return parsed(ModpackEvent, base, { kind: 'changed', modpack, versionLabel }, [
+ ...actionSearchParts(action),
+ modpack?.id,
+ modpack?.label,
+ modpack?.secondaryLabel,
+ versionLabel,
+ ])
+ }
+ case 'modpack_unlinked': {
+ const record = metadataRecord(metadata)
+ const modpack = modpackEntityFromMetadata(record, lookups)
+ const versionLabel = modpack ? null : modpackVersionLabelFromMetadata(record, lookups)
+ return parsed(ModpackEvent, base, { kind: 'unlinked', modpack, versionLabel }, [
+ ...actionSearchParts(action),
+ modpack?.id,
+ modpack?.label,
+ modpack?.secondaryLabel,
+ versionLabel,
+ ])
+ }
+ case 'port_allocation_added':
+ case 'port_allocation_removed': {
+ const port = numberField(metadataRecord(metadata), 'port')
+ if (port == null) return unknown(base, action)
+ return parsed(
+ NetworkEvent,
+ base,
+ { kind: action === 'port_allocation_added' ? 'added' : 'removed', port },
+ [...actionSearchParts(action), String(port)],
+ )
+ }
+ case 'loader_version_edited': {
+ const record = metadataRecord(metadata)
+ if (!record || !('new_version' in record)) return unknown(base, action)
+ const newLoader = record.new_loader == null ? null : valueToString(record.new_loader)
+ const newVersion = record.new_version == null ? null : valueToString(record.new_version)
+ return parsed(ConfigEvent, base, { kind: 'loader_version', newLoader, newVersion }, [
+ ...actionSearchParts(action),
+ newLoader,
+ newVersion,
+ ])
+ }
+ case 'game_version_edited': {
+ const newVersion = stringField(metadataRecord(metadata), 'new_version')
+ if (!newVersion) return unknown(base, action)
+ return parsed(ConfigEvent, base, { kind: 'game_version', newVersion }, [
+ ...actionSearchParts(action),
+ newVersion,
+ ])
+ }
+ case 'server_properties_modified': {
+ const properties = objectField(metadataRecord(metadata), 'properties')
+ if (!properties) return unknown(base, action)
+ const items = Object.entries(properties).map(
+ ([key, value]): EventEntity => ({
+ id: key,
+ label: `${key}: ${valueToString(value) ?? ''}`,
+ mono: true,
+ }),
+ )
+ return parsed(ConfigEvent, base, { kind: 'properties', properties: items }, [
+ ...actionSearchParts(action),
+ ...items.map((item) => item.label),
+ ])
+ }
+ case 'startup_command_modified': {
+ const command = stringField(metadataRecord(metadata), 'command')
+ if (!command) return unknown(base, action)
+ return parsed(ConfigEvent, base, { kind: 'startup_command', command }, [
+ ...actionSearchParts(action),
+ command,
+ ])
+ }
+ case 'java_runtime_modified': {
+ const vendor = stringField(metadataRecord(metadata), 'vendor')
+ if (!vendor) return unknown(base, action)
+ return parsed(ConfigEvent, base, { kind: 'java_runtime', vendor }, [
+ ...actionSearchParts(action),
+ vendor,
+ ])
+ }
+ case 'java_version_modified': {
+ const version = numberField(metadataRecord(metadata), 'version')
+ if (version == null) return unknown(base, action)
+ return parsed(ConfigEvent, base, { kind: 'java_version', version }, [
+ ...actionSearchParts(action),
+ String(version),
+ ])
+ }
+ case 'file_uploaded':
+ case 'file_deleted':
+ case 'file_edited': {
+ const path = stringField(metadataRecord(metadata), 'path')
+ if (!path) return unknown(base, action)
+ const kind = action.replace('file_', '')
+ return parsed(FileEvent, base, { kind, file: fileEntity(path, lookups.serverId) }, [
+ ...actionSearchParts(action),
+ path,
+ ])
+ }
+ case 'file_renamed': {
+ const record = metadataRecord(metadata)
+ const from = stringField(record, 'from')
+ const to = stringField(record, 'to')
+ if (!from || !to) return unknown(base, action)
+ return parsed(
+ FileEvent,
+ base,
+ {
+ kind: 'renamed',
+ from: fileEntity(from, lookups.serverId),
+ to: fileEntity(to, lookups.serverId),
+ },
+ [...actionSearchParts(action), from, to],
+ )
+ }
+ case 'console_command_executed': {
+ const command = stringField(metadataRecord(metadata), 'command')
+ if (!command) return unknown(base, action)
+ return parsed(ConsoleEvent, base, { command }, [...actionSearchParts(action), command])
+ }
+ case 'backup_created':
+ case 'backup_restored':
+ case 'backup_deleted': {
+ const id = stringField(metadataRecord(metadata), 'id')
+ if (!id) return unknown(base, action)
+ const kind = action.replace('backup_', '')
+ const backup = backupEntity(id, lookups)
+ return parsed(BackupEvent, base, { kind, backup: backup ?? undefined, backupId: id }, [
+ ...actionSearchParts(action),
+ backup?.label,
+ id,
+ ])
+ }
+ case 'backup_renamed': {
+ const record = metadataRecord(metadata)
+ const id = stringField(record, 'id')
+ const from = stringField(record, 'from')
+ const to = stringField(record, 'to')
+ if (!id || !from || !to) return unknown(base, action)
+ const backup = backupEntity(id, lookups)
+ return parsed(
+ BackupEvent,
+ base,
+ { kind: 'renamed', backup: backup ?? undefined, backupId: id, from, to },
+ [...actionSearchParts(action), backup?.label, from, to, id],
+ )
+ }
+ default:
+ return unknown(base, action)
+ }
+ } catch {
+ return unknown(base, action)
+ }
+}
+
+function baseProps(
+ entry: Archon.Actions.v1.ActionEntry,
+ lookups: AuditEventLookups,
+ action: string,
+): BaseEventProps {
+ return {
+ action,
+ timestamp: entry.timestamp,
+ actor: actorFromEntry(entry.actor, lookups.users),
+ world: worldFromId(entry.world_id ?? null, lookups.worldById),
+ }
+}
+
+function parsed(
+ component: Component,
+ base: BaseEventProps,
+ props: Record,
+ searchParts: unknown[],
+): ParsedAuditEvent {
+ return {
+ key: base.action,
+ component,
+ props: { ...base, ...props },
+ searchText: searchParts
+ .filter((part): part is string => typeof part === 'string' && part.length > 0)
+ .join(' ')
+ .toLowerCase(),
+ }
+}
+
+function unknown(base: BaseEventProps, rawAction: string): ParsedAuditEvent {
+ return parsed(UnknownEvent, base, { rawAction }, [rawAction])
+}
+
+function actionSearchParts(action: string, ...extra: unknown[]): unknown[] {
+ return [action, action.replaceAll('_', ' '), ...extra]
+}
+
+function actorFromEntry(
+ actor: Archon.Actions.v1.ActionUser,
+ users: Record,
+): AuditActor {
+ if (actor.type === 'support') {
+ const user = actor.user_id ? users[actor.user_id] : undefined
+ return {
+ id: 'support',
+ username: user?.username ? `Support (${user.username})` : 'support',
+ }
+ }
+
+ const user = users[actor.user_id]
+ return {
+ id: actor.user_id,
+ username: user?.username ?? actor.user_id,
+ avatarUrl: user?.avatar_url || undefined,
+ profilePath: user?.username ? `/user/${encodeURIComponent(user.username)}` : undefined,
+ }
+}
+
+function userPermissionsActionMetadata(
+ record: Record | null,
+): Archon.Actions.v1.UserPermissionsActionMetadata | null {
+ const userId = stringField(record, 'user_id')
+ if (!userId) return null
+
+ return {
+ user_id: userId,
+ permissions: permissionField(record?.permissions),
+ }
+}
+
+function permissionField(value: unknown): Archon.ServerUsers.v1.UserScope | null {
+ if (typeof value === 'number' && Number.isFinite(value)) return value
+ if (typeof value === 'string') return value.trim() || null
+ if (Array.isArray(value)) {
+ const permissions = value
+ .filter((permission): permission is string => typeof permission === 'string')
+ .map((permission) => permission.trim())
+ .filter(Boolean)
+ return permissions.length > 0 ? permissions.join(' | ') : null
+ }
+ return null
+}
+
+function worldFromId(
+ worldId: string | null,
+ worldById: Map,
+): AuditWorld | null {
+ if (!worldId) return null
+ return worldById.get(worldId) ?? { id: worldId, name: worldId }
+}
+
+function userEntity(
+ userId: string,
+ users: Record,
+): EventEntity {
+ const user = users[userId]
+ const label = user?.username ?? userId
+ return {
+ id: userId,
+ label,
+ iconUrl: user?.avatar_url || undefined,
+ iconShape: 'circle',
+ to: user?.username ? `/user/${encodeURIComponent(user.username)}` : undefined,
+ }
+}
+
+function addonList(
+ record: Record | null,
+ lookups: AuditEventLookups,
+): AuditAddonEventItem[] | null {
+ const list = arrayField(record, 'addons')
+ if (!list) return null
+
+ const addons: AuditAddonEventItem[] = []
+ for (const item of list) {
+ const addonRecord = metadataRecord(item)
+ const addonId = stringField(addonRecord, 'addon_id')
+ const versionId = stringField(addonRecord, 'version_id')
+ if (!addonId || !versionId) return null
+ addons.push(addonEntity(addonId, versionId, lookups.addons, lookups.versions))
+ }
+ return addons
+}
+
+function addonEntity(
+ addonId: string,
+ versionId: string,
+ addons: Record,
+ versions: Record,
+): AuditAddonEventItem {
+ const addon = addons[addonId]
+ const versionLabel = resolveVersionLabel(versionId, versions)
+ const projectIdOrSlug = addon?.slug || addonId
+ return {
+ addonId,
+ versionId,
+ versionLabel,
+ project: {
+ id: addonId,
+ label: addon?.title || shortId(addonId),
+ secondaryLabel: versionLabel,
+ icon: PackageIcon,
+ iconUrl: addon?.icon_url || undefined,
+ iconShape: 'square',
+ to: `/project/${encodeURIComponent(projectIdOrSlug)}/version/${encodeURIComponent(versionId)}`,
+ },
+ }
+}
+
+function resolveVersionLabel(
+ versionId: string,
+ versions: Record,
+): string {
+ const version = versions[versionId]
+ return version?.version_number || version?.name || shortId(versionId)
+}
+
+function backupEntity(id: string, lookups: AuditEventLookups): AuditBackupEventItem | null {
+ const backup = lookups.backupById.get(id)
+ if (!backup) return null
+
+ return {
+ id,
+ backupId: id,
+ found: true,
+ label: backup.name,
+ to: {
+ path: `/hosting/manage/${lookups.serverId}/backups`,
+ query: { backup: id },
+ },
+ }
+}
+
+function fileEntity(path: string, serverId: string, link = true): EventEntity {
+ return {
+ id: path,
+ label: path,
+ mono: true,
+ to: link
+ ? {
+ path: `/hosting/manage/${serverId}/files`,
+ query: {
+ path: parentPath(path),
+ editing: path,
+ },
+ }
+ : undefined,
+ }
+}
+
+function parentPath(path: string): string {
+ const normalized = path.startsWith('/') ? path : `/${path}`
+ const lastSlash = normalized.lastIndexOf('/')
+ if (lastSlash <= 0) return '/'
+ return normalized.slice(0, lastSlash)
+}
+
+function metadataRecord(value: unknown): Record | null {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
+ return value as Record
+}
+
+function objectField(
+ record: Record | null,
+ key: string,
+): Record | null {
+ return metadataRecord(record?.[key])
+}
+
+function arrayField(record: Record | null, key: string): unknown[] | null {
+ const value = record?.[key]
+ return Array.isArray(value) ? value : null
+}
+
+function stringArrayField(record: Record | null, key: string): string[] | null {
+ const array = arrayField(record, key)
+ if (!array || !array.every((item) => typeof item === 'string')) return null
+ return array
+}
+
+function modpackEntityFromMetadata(
+ record: Record | null,
+ lookups: AuditEventLookups,
+): EventEntity | null {
+ const spec = metadataRecord(record?.spec)
+ if (!spec) return null
+
+ const platform = stringField(spec, 'platform')
+ if (platform === 'modrinth') {
+ const projectId = stringField(spec, 'project_id')
+ const versionId = stringField(spec, 'version_id')
+ if (!projectId && !versionId) return null
+
+ const project = projectId ? lookups.addons[projectId] : undefined
+ const versionLabel = versionId ? resolveVersionLabel(versionId, lookups.versions) : undefined
+ const projectIdOrSlug = project?.slug || projectId
+ const label = project?.title || (projectId ? shortId(projectId) : versionLabel)
+
+ return {
+ id: projectId || versionId || 'modrinth',
+ label: label || 'Modrinth modpack',
+ secondaryLabel: versionLabel,
+ icon: PackageIcon,
+ iconUrl: project?.icon_url || undefined,
+ iconShape: 'square',
+ to: projectIdOrSlug
+ ? versionId
+ ? `/project/${encodeURIComponent(projectIdOrSlug)}/version/${encodeURIComponent(versionId)}`
+ : `/project/${encodeURIComponent(projectIdOrSlug)}`
+ : undefined,
+ title: project?.title ? undefined : projectId || versionId || undefined,
+ }
+ }
+
+ if (platform === 'local_file') {
+ const filename = stringField(spec, 'filename')
+ const name = stringField(spec, 'name')
+ const versionId = stringField(spec, 'version_id')
+ if (!filename && !name && !versionId) return null
+
+ return {
+ id: filename || name || versionId || 'local-file',
+ label: name || filename || versionId || 'Local modpack',
+ secondaryLabel: name && filename ? filename : versionId || undefined,
+ icon: PackageIcon,
+ iconShape: 'square',
+ mono: !name,
+ title: filename || name || undefined,
+ }
+ }
+
+ return null
+}
+
+function modpackVersionLabelFromMetadata(
+ record: Record | null,
+ lookups: AuditEventLookups,
+): string | null {
+ const direct = valueToString(record?.new_version)
+ if (direct != null) return direct
+
+ const spec = metadataRecord(record?.spec)
+ if (!spec) return null
+
+ const versionId = valueToString(spec.version_id)
+ if (versionId != null) return resolveVersionLabel(versionId, lookups.versions)
+
+ if (spec.platform === 'local_file') {
+ return stringField(spec, 'name') ?? stringField(spec, 'filename')
+ }
+
+ return null
+}
+
+function stringField(record: Record | null, key: string): string | null {
+ const value = record?.[key]
+ return typeof value === 'string' && value.length > 0 ? value : null
+}
+
+function numberField(record: Record | null, key: string): number | null {
+ const value = record?.[key]
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
+}
+
+function valueToString(value: unknown): string | null {
+ if (typeof value === 'string') return value
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
+ return null
+}
+
+function shortId(id: string): string {
+ if (id.length <= 12) return id
+ return id.slice(0, 8)
+}
diff --git a/packages/ui/src/components/servers/access/events/types.ts b/packages/ui/src/components/servers/access/events/types.ts
new file mode 100644
index 0000000000..791d97eb3f
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/types.ts
@@ -0,0 +1,69 @@
+import type { Archon } from '@modrinth/api-client'
+import type { Component } from 'vue'
+
+export interface AuditActor {
+ id: string
+ username: string
+ avatarUrl?: string
+ profilePath?: string
+}
+
+export interface AuditWorld {
+ id: string
+ name: string
+}
+
+export interface BaseEventProps {
+ action: string
+ timestamp: string
+ actor: AuditActor
+ world: AuditWorld | null
+}
+
+export type EventRoute =
+ | string
+ | {
+ path: string
+ query?: Record
+ }
+
+export interface EventEntity {
+ id: string
+ label: string
+ secondaryLabel?: string
+ to?: EventRoute
+ icon?: Component
+ iconUrl?: string | null
+ iconShape?: 'circle' | 'square'
+ mono?: boolean
+ muted?: boolean
+ title?: string
+}
+
+export interface AuditAddonEventItem {
+ addonId: string
+ versionId: string
+ project: EventEntity
+ versionLabel: string
+}
+
+export interface AuditBackupEventItem extends EventEntity {
+ backupId: string
+ found: boolean
+}
+
+export interface ParsedAuditEvent {
+ key: string
+ component: Component
+ props: BaseEventProps & Record
+ searchText: string
+}
+
+export interface AuditEventLookups {
+ serverId: string
+ users: Record
+ addons: Record
+ versions: Record
+ worldById: Map
+ backupById: Map
+}
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..d1d76e19f6
--- /dev/null
+++ b/packages/ui/src/components/servers/access/index.ts
@@ -0,0 +1,7 @@
+export { default as AccessTable } from './AccessTable.vue'
+export { default as AuditLogTable } from './AuditLogTable.vue'
+export * from './events'
+export { default as GrantAccessModal } from './GrantAccessModal.vue'
+export * from './permissions'
+export { default as RemoveAccessModal } from './RemoveAccessModal.vue'
+export * from './types'
diff --git a/packages/ui/src/components/servers/access/permissions.ts b/packages/ui/src/components/servers/access/permissions.ts
new file mode 100644
index 0000000000..3cf7123858
--- /dev/null
+++ b/packages/ui/src/components/servers/access/permissions.ts
@@ -0,0 +1,25 @@
+import type { Archon } from '@modrinth/api-client'
+
+import { hasServerPermission } from '../../../composables/server-permissions'
+import type { ServerAccessRole } from './types'
+
+export function apiPermissionsToAccessRole(
+ permissions: Archon.ServerUsers.v1.UserScope,
+): ServerAccessRole {
+ if (
+ hasServerPermission(permissions, 'SERVER_ADMIN') ||
+ hasServerPermission(permissions, 'MANAGE_USERS')
+ ) {
+ return 'owner'
+ }
+ if (
+ hasServerPermission(permissions, 'FILES_WRITE') ||
+ hasServerPermission(permissions, 'SETUP') ||
+ hasServerPermission(permissions, 'BACKUPS') ||
+ hasServerPermission(permissions, 'ADVANCED') ||
+ hasServerPermission(permissions, 'RESET_SERVER')
+ ) {
+ return 'editor'
+ }
+ return 'viewer'
+}
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..b3daa0d9d8
--- /dev/null
+++ b/packages/ui/src/components/servers/access/types.ts
@@ -0,0 +1,56 @@
+import type { AuditActor, AuditWorld, ParsedAuditEvent } from './events/types'
+
+export type ServerAccessRole = 'owner' | 'editor' | 'viewer'
+
+export interface ServerAccessUser extends AuditActor {
+ 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 interface ServerAuditLogEntry {
+ id: string
+ actor: AuditActor
+ world: AuditWorld | null
+ event: ParsedAuditEvent
+ timestamp: string
+}
+
+export interface ServerAuditLogFilters {
+ userId: string | null
+ worldId: string | 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
+ addAsFriend: boolean
+}
+
+export interface ServerListingOwner {
+ username: string
+ avatarUrl?: string
+}
diff --git a/packages/ui/src/components/servers/admonitions/BackupAdmonition.vue b/packages/ui/src/components/servers/admonitions/BackupAdmonition.vue
index e37f95715f..beac824d1f 100644
--- a/packages/ui/src/components/servers/admonitions/BackupAdmonition.vue
+++ b/packages/ui/src/components/servers/admonitions/BackupAdmonition.vue
@@ -32,6 +32,8 @@ defineProps<{
item: BackupAdmonitionEntry
dismissible: boolean
cancelling: boolean
+ canManageBackups?: boolean
+ permissionDeniedMessage?: string
}>()
defineEmits<{
@@ -278,12 +280,24 @@ function getDescription(item: BackupAdmonitionEntry): string {
-
+
{{ formatMessage(commonMessages.cancelButton) }}
-
+
{{ formatMessage(commonMessages.retryButton) }}
diff --git a/packages/ui/src/components/servers/admonitions/FileOperationAdmonition.vue b/packages/ui/src/components/servers/admonitions/FileOperationAdmonition.vue
index 4a57be09f1..8ce40296d5 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 Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { useFormatBytes } from '#ui/composables'
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'
@@ -55,6 +62,7 @@ const props = defineProps<{
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const ctx = injectModrinthServerContext()
+const { canWriteFiles, permissionDeniedMessage } = useServerPermissions()
const messages = defineMessages({
extracting: {
@@ -97,4 +105,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 c157327f99..81a166e389 100644
--- a/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue
+++ b/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue
@@ -12,6 +12,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'
@@ -32,6 +33,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),
@@ -311,6 +313,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 {
@@ -323,6 +326,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()
@@ -388,6 +392,8 @@ function onContentErrorDismiss() {
:fallback-phase="isOnContentTab && !syncProgress ? 'Addons' : null"
:content-error="contentError"
:dismissible="dismissible && !!contentError"
+ :retry-disabled="!canSetup"
+ :retry-disabled-tooltip="permissionDeniedMessage"
@dismiss="onContentErrorDismiss"
@retry="emit('content-retry')"
/>
@@ -408,6 +414,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..363f428cc7 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
@@ -69,23 +73,35 @@ import { IssuesIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
+import { useVIntl } from '../../../composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
+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..2666be78a4 100644
--- a/packages/ui/src/components/servers/backups/BackupItem.vue
+++ b/packages/ui/src/components/servers/backups/BackupItem.vue
@@ -38,7 +38,10 @@ const props = withDefaults(
showCopyIdAction?: boolean
showDebugInfo?: boolean
restoreDisabled?: string
+ writeDisabled?: boolean
+ writeDisabledTooltip?: string
selected?: boolean
+ highlighted?: boolean
}>(),
{
preview: false,
@@ -47,7 +50,10 @@ const props = withDefaults(
showCopyIdAction: false,
showDebugInfo: false,
restoreDisabled: undefined,
+ writeDisabled: false,
+ writeDisabledTooltip: undefined,
selected: false,
+ highlighted: false,
},
)
@@ -60,6 +66,12 @@ const backupIcon = computed(() => {
return UserRoundIcon
})
+const itemBorderClass = computed(() => {
+ if (props.selected) return 'border-brand-green'
+ if (props.highlighted) return 'border-purple backup-item-highlighted'
+ return 'border-transparent'
+})
+
const overflowMenuOptions = computed(() => {
const options: OverflowOption[] = []
@@ -81,13 +93,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
@@ -123,7 +142,7 @@ const messages = defineMessages({
@@ -215,3 +234,25 @@ const messages = defineMessages({
}}
+
+
diff --git a/packages/ui/src/components/servers/backups/BackupRenameModal.vue b/packages/ui/src/components/servers/backups/BackupRenameModal.vue
index eaed166ea1..54d090990a 100644
--- a/packages/ui/src/components/servers/backups/BackupRenameModal.vue
+++ b/packages/ui/src/components/servers/backups/BackupRenameModal.vue
@@ -29,7 +29,11 @@
-
+
Renaming...
@@ -51,23 +55,35 @@ import { IssuesIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
+import { useVIntl } from '../../../composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
+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..64dc1de2c6 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 { useVIntl } from '../../../composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
+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 b8c5af4313..2c0743ce0f 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,
@@ -15,6 +16,7 @@ export function useServerPowerAction(options?: { disabled?: Ref }) {
const { serverId, server, powerState, isSyncingContent, busyReasons } =
injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
+ const { canUsePowerActions, permissionDeniedMessage } = useServerPermissions()
const isInstalling = computed(
() =>
@@ -34,20 +36,27 @@ 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/how-ago.ts b/packages/ui/src/composables/how-ago.ts
index bd7d26cb4d..64f98875fd 100644
--- a/packages/ui/src/composables/how-ago.ts
+++ b/packages/ui/src/composables/how-ago.ts
@@ -3,9 +3,9 @@ import { LRUCache } from 'lru-cache'
import { injectI18n } from '../providers/i18n'
import { LOCALES } from './i18n.ts'
-const formatterCache = new LRUCache({ max: 5 })
+const formatterCache = new LRUCache({ max: 15 })
-export function useRelativeTime() {
+export function useRelativeTime(options?: Intl.RelativeTimeFormatOptions) {
const { locale } = injectI18n()
return (value: Date | number | string | null | undefined) => {
@@ -29,7 +29,7 @@ export function useRelativeTime() {
const months = Math.round(diff / 2629746000)
const years = Math.round(diff / 31556952000)
- const rtf = getFormatter(locale.value)
+ const rtf = getFormatter(locale.value, options)
if (Math.abs(seconds) < 60) {
return rtf.format(seconds, 'second')
@@ -49,15 +49,22 @@ export function useRelativeTime() {
}
}
-function getFormatter(locale: string): Intl.RelativeTimeFormat {
- let formatter = formatterCache.get(locale)
+function getFormatter(
+ locale: string,
+ options?: Intl.RelativeTimeFormatOptions,
+): Intl.RelativeTimeFormat {
+ const localeDefinition = LOCALES.find((loc) => loc.code === locale)
+ const numeric = options?.numeric ?? localeDefinition?.numeric ?? 'auto'
+ const style = options?.style ?? 'long'
+ const cacheKey = `${locale}:${numeric}:${style}`
+ let formatter = formatterCache.get(cacheKey)
if (!formatter) {
- const localeDefinition = LOCALES.find((loc) => loc.code === locale)
formatter = new Intl.RelativeTimeFormat(locale, {
- numeric: localeDefinition?.numeric || 'auto',
- style: 'long',
+ ...options,
+ numeric,
+ style,
})
- formatterCache.set(locale, formatter)
+ formatterCache.set(cacheKey, formatter)
}
return formatter
}
diff --git a/packages/ui/src/composables/index.ts b/packages/ui/src/composables/index.ts
index 1fea76d0db..c476e133e1 100644
--- a/packages/ui/src/composables/index.ts
+++ b/packages/ui/src/composables/index.ts
@@ -14,6 +14,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-backups-queue.ts b/packages/ui/src/composables/server-backups-queue.ts
index e05efdfe34..e05a716626 100644
--- a/packages/ui/src/composables/server-backups-queue.ts
+++ b/packages/ui/src/composables/server-backups-queue.ts
@@ -23,7 +23,7 @@ export function useServerBackupsQueue(serverId: Ref, worldId: Ref !!worldId.value),
refetchInterval: (q) => {
const data = q.state.data as Archon.BackupsQueue.v1.BackupsQueueResponse | undefined
- return data?.active_operations?.length ? 3000 : false
+ return data?.active_operations?.length ? 30_000 : false
},
})
diff --git a/packages/ui/src/composables/server-manage-core-runtime.ts b/packages/ui/src/composables/server-manage-core-runtime.ts
index 1df2f630c2..e74799361a 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, CancelUploadHandler } from '../providers/server-context'
+import type { BusyReason, CancelUploadHandler, 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,8 @@ 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 +393,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-panel-sync.ts b/packages/ui/src/composables/server-panel-sync.ts
new file mode 100644
index 0000000000..fa9d7f9ef3
--- /dev/null
+++ b/packages/ui/src/composables/server-panel-sync.ts
@@ -0,0 +1,345 @@
+import type { Archon } from '@modrinth/api-client'
+import { useQueryClient } from '@tanstack/vue-query'
+import type { ComputedRef, Ref } from 'vue'
+import { onMounted, onUnmounted, watch } from 'vue'
+
+import { injectModrinthClient } from '#ui/providers'
+
+type ReadableRef = Ref | ComputedRef
+type SyncUnsubscriber = () => void
+
+type UseServerPanelSyncOptions = {
+ serverId: ReadableRef
+ worldId: ReadableRef
+}
+
+const ACTION_LOG_INVALIDATE_DELAY_MS = 500
+
+export function useServerPanelSync(options: UseServerPanelSyncOptions) {
+ const client = injectModrinthClient()
+ const queryClient = useQueryClient()
+
+ let activeServerId: string | null = null
+ let unsubscribers: SyncUnsubscriber[] = []
+ let mounted = false
+ let actionLogInvalidateTimer: ReturnType | null = null
+
+ const legacyServerDetailKey = (serverId: string) => ['servers', 'detail', serverId] as const
+ const serverV1DetailKey = (serverId: string) => ['servers', 'v1', 'detail', serverId] as const
+ const contentListKey = (serverId: string) => ['content', 'list', 'v1', serverId] as const
+ const actionLogBaseKey = (serverId: string) =>
+ ['servers', 'action-log', 'v1', 'infinite', serverId] as const
+
+ function connect(targetServerId: string) {
+ if (!targetServerId || activeServerId === targetServerId) return
+
+ disconnect()
+ activeServerId = targetServerId
+
+ if (!client.archon.sync.getStatus(targetServerId)?.lastEventId) {
+ void invalidateCorePanelQueries(targetServerId)
+ }
+
+ unsubscribers = [
+ client.archon.sync.onAny(targetServerId, (event) => handleSyncEvent(targetServerId, event)),
+ ]
+
+ void client.archon.sync.safeConnectServer(targetServerId, { intent: 'all' }).catch((error) => {
+ console.warn(
+ `[server-panel-sync] Failed to connect sync stream for ${targetServerId}:`,
+ error,
+ )
+ })
+ }
+
+ function disconnect() {
+ if (actionLogInvalidateTimer) {
+ clearTimeout(actionLogInvalidateTimer)
+ actionLogInvalidateTimer = null
+ }
+
+ for (const unsubscribe of unsubscribers) unsubscribe()
+ unsubscribers = []
+
+ if (activeServerId) {
+ client.archon.sync.disconnect(activeServerId)
+ activeServerId = null
+ }
+ }
+
+ function handleSyncEvent(serverId: string, event: Archon.Sync.v1.SyncEvent) {
+ if (event.type === 'protocol.reset' || event.type === 'protocol.invalid') {
+ void invalidateCorePanelQueries(serverId)
+ return
+ }
+
+ if (event.type === 'protocol.error') {
+ console.warn(`[server-panel-sync] Protocol error for ${serverId}: ${event.error}`)
+ return
+ }
+
+ scheduleActionLogInvalidation(serverId)
+
+ if (event.type.startsWith('backup.')) {
+ handleBackupEvent(serverId)
+ return
+ }
+
+ switch (event.type) {
+ case 'server.patch':
+ handleServerPatch(serverId, event)
+ break
+ case 'server.network.patch':
+ handleServerNetworkPatch(serverId, event)
+ break
+ case 'server.transfer.start':
+ case 'server.transfer.done':
+ void invalidateServerDetails(serverId)
+ break
+ case 'users.patch':
+ handleUsersPatch(serverId)
+ break
+ case 'world.patch':
+ handleWorldPatch(serverId, event)
+ break
+ case 'world.startup.patch':
+ handleWorldStartupPatch(serverId, event)
+ break
+ case 'world.content.addon.patch':
+ handleWorldContentAddonPatch(serverId, event)
+ break
+ case 'world.content.base.update':
+ handleWorldContentBaseUpdate(serverId, event)
+ break
+ }
+ }
+
+ function handleServerPatch(serverId: string, event: Archon.Sync.v1.ServerPatchEvent) {
+ queryClient.setQueryData(
+ legacyServerDetailKey(serverId),
+ (current) =>
+ current
+ ? {
+ ...current,
+ name: event.name,
+ net: {
+ ...current.net,
+ domain: event.subdomain,
+ },
+ }
+ : current,
+ )
+ queryClient.setQueryData(
+ serverV1DetailKey(serverId),
+ (current) =>
+ current
+ ? {
+ ...current,
+ name: event.name,
+ subdomain: event.subdomain,
+ }
+ : current,
+ )
+ }
+
+ function handleServerNetworkPatch(
+ serverId: string,
+ event: Archon.Sync.v1.ServerNetworkPatchEvent,
+ ) {
+ queryClient.setQueryData(
+ ['servers', 'allocations', serverId],
+ event.ports,
+ )
+ void invalidateServerDetails(serverId)
+ }
+
+ function handleUsersPatch(serverId: string) {
+ void queryClient.invalidateQueries({ queryKey: ['servers', 'users', 'v1', serverId] })
+ void invalidateServerDetails(serverId)
+ }
+
+ function handleWorldPatch(serverId: string, event: Archon.Sync.v1.WorldPatchEvent) {
+ patchServerFullWorld(serverId, event.world_id, (world) => ({
+ ...world,
+ name: event.name,
+ }))
+ }
+
+ function handleWorldStartupPatch(serverId: string, event: Archon.Sync.v1.WorldStartupPatchEvent) {
+ patchServerFullWorld(serverId, event.world_id, (world) =>
+ world.content
+ ? {
+ ...world,
+ content: {
+ ...world.content,
+ java_version: event.java_version,
+ invocation: event.invocation,
+ original_invocation: event.original_invocation,
+ },
+ }
+ : world,
+ )
+ void queryClient.invalidateQueries({ queryKey: ['servers', 'startup', 'v1', serverId] })
+ }
+
+ function handleWorldContentAddonPatch(
+ serverId: string,
+ event: Archon.Sync.v1.WorldContentAddonPatchEvent,
+ ) {
+ if (event.world_id !== options.worldId.value) {
+ void invalidateContentAndServerDetails(serverId)
+ return
+ }
+
+ queryClient.setQueryData(contentListKey(serverId), (current) =>
+ current
+ ? {
+ ...current,
+ addons: mergeAddonSpecs(current.addons ?? [], event.specs),
+ }
+ : current,
+ )
+ void queryClient.invalidateQueries({ queryKey: contentListKey(serverId) })
+ }
+
+ function handleWorldContentBaseUpdate(
+ serverId: string,
+ event: Archon.Sync.v1.WorldContentBaseUpdateEvent,
+ ) {
+ if (event.world_id === options.worldId.value) {
+ queryClient.setQueryData(contentListKey(serverId), (current) =>
+ current ? { ...current, ...event.spec } : event.spec,
+ )
+ } else {
+ void queryClient.invalidateQueries({ queryKey: contentListKey(serverId) })
+ }
+
+ void queryClient.invalidateQueries({ queryKey: serverV1DetailKey(serverId) })
+ }
+
+ function handleBackupEvent(serverId: string) {
+ void queryClient.invalidateQueries({ queryKey: ['backups', 'queue', serverId] })
+ void invalidateServerDetails(serverId)
+ }
+
+ function patchServerFullWorld(
+ serverId: string,
+ worldId: string,
+ patch: (world: Archon.Servers.v1.WorldFull) => Archon.Servers.v1.WorldFull,
+ ) {
+ queryClient.setQueryData(
+ serverV1DetailKey(serverId),
+ (current) =>
+ current
+ ? {
+ ...current,
+ worlds: current.worlds.map((world) => (world.id === worldId ? patch(world) : world)),
+ }
+ : current,
+ )
+ }
+
+ function scheduleActionLogInvalidation(serverId: string) {
+ if (actionLogInvalidateTimer) clearTimeout(actionLogInvalidateTimer)
+
+ actionLogInvalidateTimer = setTimeout(() => {
+ actionLogInvalidateTimer = null
+ void queryClient.invalidateQueries({ queryKey: actionLogBaseKey(serverId) })
+ }, ACTION_LOG_INVALIDATE_DELAY_MS)
+ }
+
+ async function invalidateServerDetails(serverId: string) {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: legacyServerDetailKey(serverId) }),
+ queryClient.invalidateQueries({ queryKey: serverV1DetailKey(serverId) }),
+ ])
+ }
+
+ async function invalidateContentAndServerDetails(serverId: string) {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: contentListKey(serverId) }),
+ invalidateServerDetails(serverId),
+ ])
+ }
+
+ async function invalidateCorePanelQueries(serverId: string) {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: legacyServerDetailKey(serverId) }),
+ queryClient.invalidateQueries({ queryKey: serverV1DetailKey(serverId) }),
+ queryClient.invalidateQueries({ queryKey: contentListKey(serverId) }),
+ queryClient.invalidateQueries({ queryKey: ['backups', 'queue', serverId] }),
+ queryClient.invalidateQueries({ queryKey: ['servers', 'users', 'v1', serverId] }),
+ queryClient.invalidateQueries({ queryKey: actionLogBaseKey(serverId) }),
+ queryClient.invalidateQueries({ queryKey: ['servers', 'startup', 'v1', serverId] }),
+ queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] }),
+ ])
+ }
+
+ function mergeAddonSpecs(
+ currentAddons: Archon.Content.v1.Addon[],
+ incomingAddons: Archon.Content.v1.Addon[],
+ ): Archon.Content.v1.Addon[] {
+ const currentByFilename = new Map(
+ currentAddons.map((addon) => [normalizeAddonFilename(addon.filename), addon] as const),
+ )
+
+ return incomingAddons.map((incoming) =>
+ mergeAddonSpec(currentByFilename.get(normalizeAddonFilename(incoming.filename)), incoming),
+ )
+ }
+
+ function mergeAddonSpec(
+ current: Archon.Content.v1.Addon | undefined,
+ incoming: Archon.Content.v1.Addon,
+ ): Archon.Content.v1.Addon {
+ if (!current) return incoming
+
+ return {
+ ...current,
+ ...incoming,
+ filesize: incoming.filesize || current.filesize,
+ name: incoming.name ?? current.name,
+ owner: incoming.owner ?? current.owner,
+ icon_url: incoming.icon_url ?? current.icon_url,
+ has_update: incoming.has_update ?? current.has_update,
+ project_id: incoming.project_id ?? current.project_id,
+ version: incoming.version
+ ? {
+ ...incoming.version,
+ name: incoming.version.name ?? current.version?.name ?? null,
+ environment: incoming.version.environment ?? current.version?.environment ?? null,
+ }
+ : current.version,
+ }
+ }
+
+ function normalizeAddonFilename(filename: string): string {
+ return filename.endsWith('.disabled') ? filename.slice(0, -'.disabled'.length) : filename
+ }
+
+ onMounted(() => {
+ mounted = true
+ connect(options.serverId.value)
+ })
+
+ watch(
+ () => options.serverId.value,
+ (serverId) => {
+ if (!mounted) return
+ if (serverId) {
+ connect(serverId)
+ } else {
+ disconnect()
+ }
+ },
+ )
+
+ onUnmounted(() => {
+ mounted = false
+ disconnect()
+ })
+
+ return {
+ disconnect,
+ }
+}
diff --git a/packages/ui/src/composables/server-permissions.ts b/packages/ui/src/composables/server-permissions.ts
new file mode 100644
index 0000000000..2cffaebf54
--- /dev/null
+++ b/packages/ui/src/composables/server-permissions.ts
@@ -0,0 +1,114 @@
+import type { 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'
+
+export type ServerPermissionName = keyof typeof Archon.ServerUsers.v1.UserScope
+
+type ServerPermissionValue = Archon.Servers.v0.UserScope | Archon.ServerUsers.v1.UserScope
+
+const U64_SIZE = 64n
+const U64_MODULUS = 1n << U64_SIZE
+
+export const serverPermissionBits = {
+ NONE: 0n,
+ BASE_READ: 1n << 63n,
+ POWER_ACTIONS: 1n << 62n,
+ FILES_WRITE: 1n << 61n,
+ SETUP: 1n << 60n,
+ BACKUPS: 1n << 59n,
+ ADVANCED: 1n << 58n,
+ RESET_SERVER: 1n << 57n,
+ MANAGE_USERS: 1n << 56n,
+ SUPPORT_AGENT: 1n,
+ INFRA_MANAGER: 1n << 1n,
+ INFRA_MANAGER_READ: 1n << 2n,
+ INFRA_SERVERS_XFER: 1n << 3n,
+ SERVER_ADMIN: ((1n << 64n) - 1n) ^ ((1n << 15n) - 1n),
+} as const satisfies Record
+
+function parsePermissionNumber(value: number) {
+ const bigintValue = BigInt(value)
+ return bigintValue < 0n ? bigintValue + U64_MODULUS : bigintValue
+}
+
+function parsePermissionString(value: string) {
+ const numericValue = Number(value)
+ if (value.trim() !== '' && Number.isFinite(numericValue)) {
+ return parsePermissionNumber(numericValue)
+ }
+
+ const permissions = value
+ .split('|')
+ .map((permission) => permission.trim())
+ .filter((permission): permission is ServerPermissionName => permission in serverPermissionBits)
+
+ if (permissions.length === 0) return 0n
+
+ return permissions.reduce((mask, permission) => mask | serverPermissionBits[permission], 0n)
+}
+
+function parsePermissions(permissions: ServerPermissionValue) {
+ return typeof permissions === 'number'
+ ? parsePermissionNumber(permissions)
+ : parsePermissionString(permissions)
+}
+
+function hasPermissionBit(permissions: ServerPermissionValue, scope: ServerPermissionName) {
+ const permission = serverPermissionBits[scope]
+ if (permission === 0n) return true
+
+ const permissionsMask = parsePermissions(permissions)
+ return (permissionsMask & permission) === permission
+}
+
+export function hasServerPermission(
+ permissions: ServerPermissionValue,
+ 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 d908bfb034..3a600cc760 100644
--- a/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue
+++ b/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue
@@ -55,6 +55,9 @@ interface Props {
hideSwitchVersion?: boolean
overflowOptions?: OverflowMenuOption[]
disabled?: boolean
+ disabledTooltip?: string | null
+ toggleDisabled?: boolean
+ toggleDisabledTooltip?: string | null
showCheckbox?: boolean
hideDelete?: boolean
hideActions?: boolean
@@ -73,6 +76,9 @@ const props = withDefaults(defineProps(), {
hideSwitchVersion: false,
overflowOptions: undefined,
disabled: false,
+ disabledTooltip: undefined,
+ toggleDisabled: false,
+ toggleDisabledTooltip: undefined,
showCheckbox: false,
hideDelete: false,
hideActions: false,
@@ -98,6 +104,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) {
@@ -173,8 +180,19 @@ const deleteHovered = ref(false)
>
{{ project.title }}
-
-
+
+
+
+
{{ formatMessage(clientWarningMessage) }}
@@ -283,7 +301,11 @@ const deleteHovered = ref(false)
hover-color-fill="background"
>
@@ -296,7 +318,11 @@ const deleteHovered = ref(false)
type="transparent"
>
@@ -307,8 +333,13 @@ const deleteHovered = ref(false)
emit('update:enabled', val as boolean)"
@@ -317,11 +348,13 @@ const deleteHovered = ref(false)
{
{
(), {
contentTypeLabel: undefined,
isBusy: false,
+ busyTooltip: undefined,
isBulkOperating: false,
bulkOperation: null,
bulkProgress: 0,
@@ -199,9 +201,11 @@ 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 5097a4630f..12ea810455 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
@@ -216,7 +216,10 @@
@@ -393,6 +396,8 @@ const props = withDefaults(
loading?: boolean
/** Whether changelog is being loaded for the selected version */
loadingChangelog?: boolean
+ actionDisabled?: boolean
+ actionDisabledTooltip?: string
}>(),
{
projectType: undefined,
@@ -401,6 +406,8 @@ const props = withDefaults(
header: undefined,
loading: false,
loadingChangelog: false,
+ actionDisabled: false,
+ actionDisabledTooltip: undefined,
},
)
@@ -614,6 +621,7 @@ function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
}
function handleUpdate(event: MouseEvent) {
+ if (props.actionDisabled) return
if (selectedVersion.value) {
const changesGameVersion = versionChangesGameVersion(
selectedVersion.value,
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..01f3a70198 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,21 @@ 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 79a2406b41..edf1c8bb67 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,7 +36,8 @@ interface Props {
modpackName?: string
modpackIconUrl?: string
enableToggle?: boolean
- busy?: boolean
+ actionDisabled?: boolean
+ actionDisabledTooltip?: string | null
getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[]
switchVersion?: (item: ContentItem) => void
}
@@ -45,7 +46,8 @@ const props = withDefaults(defineProps(), {
modpackName: undefined,
modpackIconUrl: undefined,
enableToggle: false,
- busy: false,
+ actionDisabled: false,
+ actionDisabledTooltip: undefined,
getOverflowOptions: undefined,
switchVersion: undefined,
})
@@ -54,6 +56,7 @@ const emit = defineEmits<{
'update:enabled': [item: ContentItem, value: boolean]
'bulk:enable': [items: ContentItem[]]
'bulk:disable': [items: ContentItem[]]
+ hide: []
}>()
const messages = defineMessages({
@@ -250,12 +253,16 @@ const tableItems = computed(() =>
: undefined,
...(props.enableToggle ? { enabled: item.enabled } : {}),
installing: item.installing === true,
+ toggleDisabled: props.actionDisabled,
+ toggleDisabledTooltip: props.actionDisabled ? props.actionDisabledTooltip : undefined,
isClientOnly:
isClientOnlyEnvironment(item.environment) ||
!!item.pack_client_retained ||
!!item.pack_client_depends,
clientWarning: getClientWarningType(item),
- disabled: props.busy || disabledIds.value.has(item.file_name) || item.installing === true,
+ disabled:
+ props.actionDisabled || disabledIds.value.has(item.file_name) || item.installing === true,
+ disabledTooltip: props.actionDisabled ? props.actionDisabledTooltip : undefined,
overflowOptions: [
...(props.switchVersion
? [
@@ -286,20 +293,20 @@ function getTypeIcon(type: string) {
}
function handleEnabledChange(fileName: string, value: boolean) {
- if (props.busy) return
+ 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.busy) return
+ if (props.actionDisabled) return
emit('bulk:enable', [...selectedItems.value])
selectedIds.value = []
}
function bulkDisable() {
- if (props.busy) return
+ if (props.actionDisabled) return
emit('bulk:disable', [...selectedItems.value])
selectedIds.value = []
}
@@ -326,6 +333,10 @@ function hide() {
modal.value?.hide()
}
+function handleHide() {
+ emit('hide')
+}
+
function getState(): ModpackContentModalState | null {
if (!items.value.length) return null
return {
@@ -383,6 +394,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
ref="modal"
:max-width="'min(928px, calc(95vw - 10rem))'"
:width="'min(928px, calc(95vw - 10rem))'"
+ :on-hide="handleHide"
no-padding
>
@@ -558,7 +570,8 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
(() => {
id,
disabled:
isChanging(id) || ctx.isBusy.value || isBulkOperating.value || item.installing === true,
+ disabledTooltip: ctx.isBusy.value ? (ctx.busyMessage?.value ?? null) : null,
+ toggleDisabled: ctx.isBusy.value,
+ toggleDisabledTooltip: ctx.isBusy.value ? (ctx.busyMessage?.value ?? null) : null,
installing: item.installing === true,
hasUpdate: item.has_update,
isClientOnly:
@@ -321,7 +324,7 @@ function handleDeleteById(id: string, event?: MouseEvent) {
const item = ctx.items.value.find((i) => getItemId(i) === id)
if (item) {
pendingDeletionItems.value = [item]
- if (event?.shiftKey) {
+ if (event?.shiftKey && !ctx.isBusy.value) {
confirmDelete()
} else {
confirmDeletionModal.value?.show()
@@ -331,7 +334,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()
@@ -339,6 +342,7 @@ function showBulkDeleteModal(event?: MouseEvent) {
}
async function confirmDelete() {
+ if (ctx.isBusy.value) return
const itemsToDelete = [...pendingDeletionItems.value]
pendingDeletionItems.value = []
if (itemsToDelete.length === 0) return
@@ -383,6 +387,7 @@ async function confirmDelete() {
}
async function handleToggleEnabledById(id: string, _value: boolean) {
+ if (ctx.isBusy.value) return
const item = ctx.items.value.find((i) => getItemId(i) === id)
if (!item) return
markChanging(id)
@@ -394,6 +399,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) {
@@ -414,6 +420,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) {
@@ -455,7 +462,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()
@@ -467,7 +474,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()
@@ -475,6 +482,7 @@ function promptUpdateSelected(event?: MouseEvent) {
}
async function confirmBulkUpdate() {
+ if (ctx.isBusy.value) return
const items = pendingBulkUpdateItems.value
if (items.length === 0 || !hasBulkUpdateSupport.value) return
@@ -525,12 +533,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)
"
@@ -673,14 +677,18 @@ const confirmUnlinkModal = ref>()
color-fill="text"
hover-color-fill="background"
>
-
+
{{ formatMessage(messages.updateAll) }}
-
+
{{ formatMessage(commonMessages.refreshButton) }}
@@ -759,6 +767,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"
@@ -780,7 +789,6 @@ const confirmUnlinkModal = ref>()
>
@@ -843,7 +851,6 @@ const confirmUnlinkModal = ref>()
>
@@ -859,6 +866,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..423635d6e8 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
@@ -37,6 +37,7 @@
@@ -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 45e903ee0c..7e9a83523c 100644
--- a/packages/ui/src/layouts/shared/installation-settings/layout.vue
+++ b/packages/ui/src/layouts/shared/installation-settings/layout.vue
@@ -106,6 +106,14 @@ 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
@@ -120,6 +128,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
@@ -140,6 +149,7 @@ function handleModpackUpdateRequest(version: Labrinth.Versions.v2.Version, event
}
function handleModpackUpdateConfirm() {
+ if (ctx.isBusy.value) return
const version = pendingUpdateVersion.value
if (version) {
contentUpdaterModal.value?.hide()
@@ -155,16 +165,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()
}
@@ -174,6 +187,7 @@ const emit = defineEmits<{
}>()
function handleIncompatibleResetServer() {
+ if (ctx.isBusy.value) return
form.cancelPreview()
form.cancelEditing()
emit('reset-server')
@@ -389,6 +403,7 @@ const messages = defineMessages({
(e.shiftKey ? handleUnlink() : unlinkModal?.show())"
@@ -445,6 +461,7 @@ const messages = defineMessages({
@@ -537,6 +555,7 @@ const messages = defineMessages({
@@ -580,6 +602,7 @@ const messages = defineMessages({
@@ -672,6 +702,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..133342ce11 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..cd08bccbaf
--- /dev/null
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/access.vue
@@ -0,0 +1,1462 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ 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 74e044186a..b96d9fac6e 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/backups.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/backups.vue
@@ -28,11 +28,27 @@
-
-
-
+
+
+
@@ -122,6 +138,7 @@
v-for="(backup, backupIndex) in group.backups"
:key="`backup-${backup.id}`"
class="flex gap-2"
+ :data-backup-id="backup.id"
>
triggerDownloadAnimation()"
- @rename="() => renameBackupModal?.show(backup)"
- @restore="() => restoreBackupModal?.show(backup)"
+ @rename="() => showRenameBackupModal(backup)"
+ @restore="() => showRestoreBackupModal(backup)"
@delete="
(skipConfirmation?: boolean) =>
- skipConfirmation ? deleteBackup(backup) : deleteBackupModal?.show(backup)
+ skipConfirmation ? deleteBackup(backup) : showDeleteBackupModal(backup)
"
/>
@@ -191,7 +211,12 @@
-
+
{{ formatMessage(commonMessages.deleteLabel) }}
@@ -244,7 +269,7 @@ import { CalendarIcon, DownloadIcon, IssuesIcon, PlusIcon, TrashIcon } from '@mo
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import type { Component } from 'vue'
-import { computed, ref } from 'vue'
+import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
@@ -261,6 +286,7 @@ import BackupRestoreModal from '#ui/components/servers/backups/BackupRestoreModa
import { useBackupsSelection } from '#ui/composables/hosting/backups-selection'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
+import { useServerPermissions } from '#ui/composables/server-permissions'
import { useBulkOperation } from '#ui/layouts/shared/content-tab/composables/bulk-operations'
import {
injectModrinthClient,
@@ -327,6 +353,7 @@ const messages = defineMessages({
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
+const { canManageBackups, permissionDeniedMessage } = useServerPermissions()
const filterPillOptions = computed(() => [
{ id: 'manual', label: formatMessage(messages.filterManual) },
@@ -344,6 +371,7 @@ const props = defineProps<{
const route = useRoute()
const serverId = route.params.id as string
+const BACKUP_HIGHLIGHT_DURATION_MS = 5_000
defineEmits(['onDownload'])
@@ -455,6 +483,72 @@ const groupedBackups = computed((): BackupGroup[] => {
})
const displayOrderedBackups = computed(() => groupedBackups.value.flatMap((g) => g.backups))
+const focusedBackupId = computed(() =>
+ typeof route.query.backup === 'string' ? route.query.backup : null,
+)
+const highlightedBackupId = ref(null)
+let highlightedBackupTimeout: ReturnType | null = null
+let lastHighlightedFocusedBackupId: string | null = null
+let lastScrolledFocusedBackupId: string | null = null
+
+watch(
+ [focusedBackupId, displayOrderedBackups],
+ async ([backupId]) => {
+ if (!backupId) {
+ lastHighlightedFocusedBackupId = null
+ lastScrolledFocusedBackupId = null
+ clearHighlightedBackup()
+ return
+ }
+ if (!displayOrderedBackups.value.some((backup) => backup.id === backupId)) return
+
+ if (lastHighlightedFocusedBackupId !== backupId) {
+ lastHighlightedFocusedBackupId = backupId
+ highlightBackup(backupId)
+ }
+ if (lastScrolledFocusedBackupId === backupId) return
+ if (typeof document === 'undefined') return
+
+ lastScrolledFocusedBackupId = backupId
+ await nextTick()
+ const escapedBackupId =
+ typeof CSS !== 'undefined' && CSS.escape
+ ? CSS.escape(backupId)
+ : backupId.replaceAll('"', '\\"')
+ document
+ .querySelector(`[data-backup-id="${escapedBackupId}"]`)
+ ?.scrollIntoView({ block: 'center', behavior: 'smooth' })
+ },
+ { immediate: true },
+)
+
+onBeforeUnmount(() => {
+ if (highlightedBackupTimeout) {
+ clearTimeout(highlightedBackupTimeout)
+ }
+})
+
+function highlightBackup(backupId: string) {
+ highlightedBackupId.value = backupId
+
+ if (highlightedBackupTimeout) {
+ clearTimeout(highlightedBackupTimeout)
+ }
+
+ highlightedBackupTimeout = setTimeout(() => {
+ highlightedBackupId.value = null
+ highlightedBackupTimeout = null
+ }, BACKUP_HIGHLIGHT_DURATION_MS)
+}
+
+function clearHighlightedBackup() {
+ highlightedBackupId.value = null
+
+ if (highlightedBackupTimeout) {
+ clearTimeout(highlightedBackupTimeout)
+ highlightedBackupTimeout = null
+ }
+}
const {
selectedIds,
@@ -475,6 +569,9 @@ const restoreBackupModal = ref>()
const deleteBackupModal = ref>()
const backupRestoreDisabled = computed(() => {
+ if (!canManageBackups.value) {
+ return permissionDeniedMessage.value
+ }
if (props.isServerRunning) {
return 'Cannot restore backup while server is running'
}
@@ -488,6 +585,9 @@ const backupRestoreDisabled = computed(() => {
})
const backupCreationDisabled = computed(() => {
+ if (!canManageBackups.value) {
+ return permissionDeniedMessage.value
+ }
const quota = server.value.backup_quota
if (quota !== undefined) {
const usedCount = backups.value.length ?? server.value.used_backup_quota ?? 0
@@ -505,19 +605,37 @@ const backupCreationDisabled = computed(() => {
})
const showCreateModel = () => {
+ if (backupCreationDisabled.value) return
createBackupModal.value?.show()
}
+function showRenameBackupModal(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
+ if (!canManageBackups.value) return
+ renameBackupModal.value?.show(backup)
+}
+
+function showRestoreBackupModal(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
+ if (backupRestoreDisabled.value) return
+ restoreBackupModal.value?.show(backup)
+}
+
+function showDeleteBackupModal(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
+ if (!canManageBackups.value) return
+ deleteBackupModal.value?.show(backup)
+}
+
function clearBackupFilters() {
selectedFilters.value = []
}
function confirmBulkDelete() {
+ if (!canManageBackups.value) return
if (!selectedBackups.value.length) return
deleteBackupModal.value?.showBulk(selectedBackups.value)
}
async function bulkDelete(toRemove: Archon.BackupsQueue.v1.BackupQueueBackup[]) {
+ if (!canManageBackups.value) return
if (!toRemove.length) return
isBulkOperating.value = true
@@ -548,6 +666,7 @@ function useQueueDeleteFor(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
}
function deleteBackup(backup?: Archon.BackupsQueue.v1.BackupQueueBackup) {
+ if (!canManageBackups.value) return
if (!backup) {
addNotification({
type: 'error',
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/content.vue b/packages/ui/src/layouts/wrapped/hosting/manage/content.vue
index 46f44fc4e7..09814deaff 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/content.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/content.vue
@@ -9,6 +9,7 @@ import { useRoute, useRouter } from 'vue-router'
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
+import { useServerPermissions } from '#ui/composables/server-permissions'
import {
injectModrinthClient,
injectModrinthServerContext,
@@ -123,6 +124,7 @@ const contentUploadSession = useUploadSessionUpload({
})
const { addNotification } = injectNotificationManager()
const { openServerSettings, browseServerContent } = injectServerSettingsModal()
+const { canSetup, permissionDeniedMessage } = useServerPermissions()
const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
@@ -136,6 +138,7 @@ const type = computed(() => {
})
const queryKey = computed(() => ['content', 'list', 'v1', serverId])
+const modpackContentQueryKey = computed(() => ['content', 'list', 'v1', serverId, 'modpack'])
function getContentOwnerAvatarUrl(owner: ContentOwnerAvatarSource) {
const ownerId = owner.type === 'user' ? owner.name || owner.id : owner.id
@@ -150,6 +153,44 @@ const contentQuery = useQuery({
staleTime: 0,
})
+const isModpackContentModalOpen = ref(false)
+const modpackContentQuery = useQuery({
+ queryKey: modpackContentQueryKey,
+ queryFn: () =>
+ client.archon.content_v1.getAddons(serverId, worldId.value!, {
+ from_modpack: true,
+ }),
+ enabled: computed(() => isModpackContentModalOpen.value && worldId.value !== null),
+ staleTime: 0,
+})
+
+const setupActionDisabled = computed(() => !canSetup.value || busyReasons.value.length > 0)
+const setupActionBusyMessage = computed(() => {
+ if (!canSetup.value) return permissionDeniedMessage.value
+
+ const bannerCoversInstalling =
+ server.value?.status === 'installing' ||
+ isSyncingContent.value ||
+ busyReasons.value.some(
+ (r) =>
+ r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content',
+ )
+ const filteredReasons = busyReasons.value.filter((r) => {
+ if (
+ bannerCoversInstalling &&
+ (r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content')
+ )
+ return false
+ if (
+ r.reason.id === 'servers.busy.backup-creating' ||
+ r.reason.id === 'servers.busy.backup-restoring'
+ )
+ return false
+ return true
+ })
+ return filteredReasons.length > 0 ? formatMessage(filteredReasons[0].reason) : null
+})
+
const modpackProjectId = computed(() => {
const spec = contentQuery.data.value?.modpack?.spec
return spec?.platform === 'modrinth' ? spec.project_id : null
@@ -688,12 +729,14 @@ const toggleMutation = useMutation({
})
async function handleToggleEnabled(item: ContentItem) {
+ if (setupActionDisabled.value) return
const addon = addonLookup.value.get(item.file_name)
if (!addon) return
await toggleMutation.mutateAsync({ addon })
}
async function handleDeleteItem(item: ContentItem) {
+ if (setupActionDisabled.value) return
const addon = addonLookup.value.get(item.file_name)
if (!addon) return
await deleteMutation.mutateAsync({ addon })
@@ -708,6 +751,7 @@ function itemsToAddonRequests(items: ContentItem[]): Archon.Content.v1.RemoveAdd
}
async function handleBulkDelete(items: ContentItem[]) {
+ if (setupActionDisabled.value) return
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
try {
@@ -723,6 +767,7 @@ async function handleBulkDelete(items: ContentItem[]) {
}
async function handleBulkEnable(items: ContentItem[]) {
+ if (setupActionDisabled.value) return
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
try {
@@ -738,6 +783,7 @@ async function handleBulkEnable(items: ContentItem[]) {
}
async function handleBulkDisable(items: ContentItem[]) {
+ if (setupActionDisabled.value) return
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
try {
@@ -760,6 +806,15 @@ const updatingProject = ref(null)
const updatingModpack = ref(false)
const loadingChangelog = ref(false)
+watch(
+ () => modpackContentQuery.data.value?.addons,
+ (addons) => {
+ if (!isModpackContentModalOpen.value || !addons) return
+ modpackAddons.value = addons
+ modpackContentModal.value?.setItems(addons.map(addonToContentItem))
+ },
+)
+
const updatingProjectId = computed(() => updatingProject.value?.project?.id ?? null)
const projectVersionsQuery = useQuery({
@@ -797,6 +852,7 @@ const currentLoader = computed(
)
function handleBrowseContent() {
+ if (setupActionDisabled.value) return
const contentType = type.value
if (browseServerContent && ['mod', 'plugin', 'datapack'].includes(contentType)) {
browseServerContent({
@@ -814,6 +870,7 @@ function handleBrowseContent() {
}
function handleUploadFiles() {
+ if (setupActionDisabled.value) return
const input = document.createElement('input')
input.type = 'file'
input.multiple = true
@@ -876,15 +933,16 @@ function addonToContentItem(addon: AddonWithUiState): ContentItem {
}
async function handleViewModpackContent() {
+ isModpackContentModalOpen.value = true
modpackContentModal.value?.showLoading()
try {
- const data = await client.archon.content_v1.getAddons(serverId, worldId.value!, {
- from_modpack: true,
- })
+ const { data } = await modpackContentQuery.refetch()
+ if (!data) throw new Error('Failed to load modpack content')
modpackAddons.value = data.addons ?? []
const items = (data.addons ?? []).map(addonToContentItem)
modpackContentModal.value?.show(items)
} catch (err) {
+ isModpackContentModalOpen.value = false
modpackContentModal.value?.hide()
addNotification({
type: 'error',
@@ -895,6 +953,7 @@ async function handleViewModpackContent() {
}
async function handleModpackContentToggle(item: ContentItem) {
+ if (setupActionDisabled.value) return
const addon = addonLookup.value.get(item.file_name)
if (!addon) return
modpackContentModal.value?.updateItem(item.file_name, { disabled: true })
@@ -903,6 +962,18 @@ async function handleModpackContentToggle(item: ContentItem) {
modpackAddons.value = modpackAddons.value.map((a) =>
a.filename === addon.filename ? { ...a, disabled: !addon.disabled } : a,
)
+ queryClient.setQueryData(
+ modpackContentQueryKey.value,
+ (oldData: Archon.Content.v1.Addons | undefined) =>
+ oldData
+ ? {
+ ...oldData,
+ addons: (oldData.addons ?? []).map((a) =>
+ a.filename === addon.filename ? { ...a, disabled: !addon.disabled } : a,
+ ),
+ }
+ : oldData,
+ )
modpackContentModal.value?.updateItem(item.file_name, {
enabled: !item.enabled,
disabled: false,
@@ -913,6 +984,7 @@ async function handleModpackContentToggle(item: ContentItem) {
}
async function handleModpackBulkToggle(items: ContentItem[], enable: boolean) {
+ if (setupActionDisabled.value) return
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
@@ -930,6 +1002,20 @@ async function handleModpackBulkToggle(items: ContentItem[], enable: boolean) {
} else {
await client.archon.content_v1.disableAddons(serverId, worldId.value!, requests)
}
+ queryClient.setQueryData(
+ modpackContentQueryKey.value,
+ (oldData: Archon.Content.v1.Addons | undefined) =>
+ oldData
+ ? {
+ ...oldData,
+ addons: (oldData.addons ?? []).map((addon) =>
+ items.some((item) => item.file_name === addon.filename)
+ ? { ...addon, disabled: !enable }
+ : addon,
+ ),
+ }
+ : oldData,
+ )
await queryClient.invalidateQueries({ queryKey: queryKey.value })
} catch (err) {
for (const item of items) {
@@ -951,6 +1037,7 @@ function handleModpackUnlink() {
}
async function handleModpackUnlinkConfirm() {
+ if (setupActionDisabled.value) return
try {
await client.archon.content_v1.unlinkModpack(serverId, worldId.value!)
await contentQuery.refetch()
@@ -964,6 +1051,7 @@ async function handleModpackUnlinkConfirm() {
}
async function handleBulkUpdate(items: ContentItem[]) {
+ if (setupActionDisabled.value) return
const addons = items
.filter((item) => item.has_update)
.map((item) => ({
@@ -1063,6 +1151,7 @@ function resetUpdateState() {
}
function handleModalUpdate(selectedVersion: Labrinth.Versions.v2.Version, event?: MouseEvent) {
+ if (setupActionDisabled.value) return
if (updatingModpack.value) {
pendingModpackUpdateVersion.value = selectedVersion
@@ -1100,6 +1189,7 @@ function setAddonInstalling(filename: string, installing: boolean) {
}
async function performUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
+ if (setupActionDisabled.value) return
const item = updatingProject.value
if (item) {
setAddonInstalling(item.file_name, true)
@@ -1142,6 +1232,7 @@ async function performUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
}
function handleModpackUpdateConfirm() {
+ if (setupActionDisabled.value) return
if (pendingModpackUpdateVersion.value) {
contentUpdaterModal.value?.hide()
performUpdate(pendingModpackUpdateVersion.value)
@@ -1177,32 +1268,10 @@ provideContentManager({
error: computed(() => contentQuery.error.value ?? null),
modpack,
isPackLocked: ref(false),
- isBusy: computed(() => busyReasons.value.length > 0),
- busyMessage: computed(() => {
- const bannerCoversInstalling =
- server.value?.status === 'installing' ||
- isSyncingContent.value ||
- busyReasons.value.some(
- (r) =>
- r.reason.id === 'servers.busy.installing' ||
- r.reason.id === 'servers.busy.syncing-content',
- )
- const filteredReasons = busyReasons.value.filter((r) => {
- if (
- bannerCoversInstalling &&
- (r.reason.id === 'servers.busy.installing' ||
- r.reason.id === 'servers.busy.syncing-content')
- )
- return false
- if (
- r.reason.id === 'servers.busy.backup-creating' ||
- r.reason.id === 'servers.busy.backup-restoring'
- )
- return false
- return true
- })
- return filteredReasons.length > 0 ? formatMessage(filteredReasons[0].reason) : null
- }),
+ isBusy: setupActionDisabled,
+ busyMessage: setupActionBusyMessage,
+ disableAddContent: computed(() => !canSetup.value),
+ disableAddContentTooltip: permissionDeniedMessage.value,
contentTypeLabel: type,
toggleEnabled: handleToggleEnabled,
deleteItem: handleDeleteItem,
@@ -1253,15 +1322,24 @@ provideContentManager({
-
+
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/files.vue b/packages/ui/src/layouts/wrapped/hosting/manage/files.vue
index 66b5e099fa..b62fe58237 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/files.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/files.vue
@@ -8,6 +8,7 @@ import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
import { useReadyState } from '#ui/composables'
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
import { useVIntl } from '#ui/composables/i18n'
+import { useServerPermissions } from '#ui/composables/server-permissions'
import {
injectModrinthClient,
injectModrinthServerContext,
@@ -43,6 +44,7 @@ const fileUploadSession = useUploadSessionUpload({
})
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
+const { canWriteFiles, canUsePowerActions, permissionDeniedMessage } = useServerPermissions()
const route = useRoute()
const router = useRouter()
@@ -52,6 +54,10 @@ const serverBusy = computed(() => busyReasons.value.length > 0)
const busyTooltip = computed(() =>
busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined,
)
+const fileWriteDisabled = computed(() => !canWriteFiles.value || serverBusy.value)
+const fileWriteDisabledTooltip = computed(() =>
+ canWriteFiles.value ? busyTooltip.value : permissionDeniedMessage.value,
+)
const nonBackupBusyReasons = computed(() =>
busyReasons.value.filter(
(r) =>
@@ -325,6 +331,7 @@ const createMutation = useMutation({
// Extraction
async function extractFile(path: string, override: boolean, dry: boolean) {
+ if (fileWriteDisabled.value) return
if (dry) {
return await client.kyros.files_v0.extractFile(path, override, true)
}
@@ -346,6 +353,7 @@ async function readFileAsBlob(path: string): Promise {
}
async function writeFile(path: string, content: string): Promise {
+ if (fileWriteDisabled.value) return
await client.kyros.files_v0.updateFile(path, content)
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
}
@@ -383,6 +391,7 @@ onMounted(async () => {
// Restart
async function restartServer() {
+ if (!canUsePowerActions.value) return
await client.archon.servers_v0.power(serverId, 'Restart')
}
@@ -392,7 +401,7 @@ function getSessionUploadFilename(fileName: string) {
}
async function uploadFiles(files: File[]) {
- if (files.length === 0) return
+ if (fileWriteDisabled.value || files.length === 0) return
try {
const result = await fileUploadSession.uploadFiles(
@@ -426,16 +435,20 @@ provideFileManager({
startEditing,
stopEditing,
createItem: async (name, type) => {
+ if (fileWriteDisabled.value) return
const path = `${currentPath.value}/${name}`.replace('//', '/')
await createMutation.mutateAsync({ path, type })
},
renameItem: async (path, newName) => {
+ if (fileWriteDisabled.value) return
await renameMutation.mutateAsync({ path, newName })
},
moveItem: async (source, destination) => {
+ if (fileWriteDisabled.value) return
await moveMutation.mutateAsync({ source, destination })
},
deleteItem: async (path, recursive) => {
+ if (fileWriteDisabled.value) return
await deleteMutation.mutateAsync({ path, recursive })
},
readFile,
@@ -446,14 +459,14 @@ provideFileManager({
cancelUpload,
uploadState,
refresh: refreshList,
- isBusy: serverBusy,
- busyTooltip,
+ isBusy: fileWriteDisabled,
+ busyTooltip: fileWriteDisabledTooltip,
busyWarning,
extractFile,
prefetchDirectory,
prefetchFile,
showInstallFromUrl: true,
- canRestart: true,
+ canRestart: canUsePowerActions.value,
restartServer,
canShareToMclogs: true,
})
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue
index 9699b004b0..a3f89009e2 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue
@@ -68,7 +68,7 @@
{{ formatMessage(messages.errorDetails) }}
-
+
-
-
-
-
- {{ formatMessage(messages.noServersFound) }}
+
+
+ {{ formatMessage(messages.yourServersTitle) }}
+
+
+
+
+
+
+ {{ formatMessage(messages.noOwnedServersFound) }}
+
+
+
+
+
+ {{ formatMessage(messages.sharedServersTitle) }}
+
+
+
+
+
+
+ {{ formatMessage(messages.noSharedServersFound) }}
+
+
+
+
+ {{ formatMessage(messages.noServersFound) }}
+
@@ -210,7 +248,6 @@ import {
useServerBackupDownload,
useVIntl,
} from '@modrinth/ui'
-import type { ModrinthServersFetchError } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useIntervalFn } from '@vueuse/core'
import dayjs from 'dayjs'
@@ -220,6 +257,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 +308,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,19 +616,15 @@ 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 }
function isSetToCancel(server: Archon.Servers.v0.Server): boolean {
return (
@@ -611,14 +661,56 @@ 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))
-})
+function isServerOwnedByCurrentUser(server: Archon.Servers.v0.Server): boolean {
+ return server.owner_id === auth.user.value?.id
+}
+
+function getServerOwner(server: Archon.Servers.v0.Server): ServerListingOwner | undefined {
+ const owner = serverResponse.value?.users?.[server.owner_id]
+ if (!owner) return undefined
+
+ return {
+ username: owner.username,
+ avatarUrl: owner.avatar_url ?? undefined,
+ }
+}
+
+const ownedServerList = computed(() =>
+ serverList.value.filter((server) => !filesExpired(server) && isServerOwnedByCurrentUser(server)),
+)
+const sharedServerList = computed(() =>
+ serverList.value
+ .filter((server) => !filesExpired(server) && !isServerOwnedByCurrentUser(server))
+ .map((server) => ({
+ ...server,
+ owner: getServerOwner(server),
+ })),
+)
+
+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) => {
@@ -682,6 +774,10 @@ function handleError(err: unknown) {
})
}
+function formatFetchError(error: unknown) {
+ return error instanceof Error && error.message ? error.message : 'Unknown error'
+}
+
function handleSignIn() {
void auth.requestSignIn('/hosting/manage')
}
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/overview.vue b/packages/ui/src/layouts/wrapped/hosting/manage/overview.vue
index e8e6c8463f..a644f2c4e9 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/overview.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/overview.vue
@@ -41,6 +41,7 @@ import { computed, ref, watch } from 'vue'
import ServerManageStats from '#ui/components/servers/ServerManageStats.vue'
import { useModrinthServersConsole } from '#ui/composables'
+import { useServerPermissions } from '#ui/composables/server-permissions'
import { ConsolePageLayout, provideConsoleManager } from '#ui/layouts/shared/console'
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
@@ -64,6 +65,7 @@ const {
powerStateDetails: _powerStateDetails,
} = injectModrinthServerContext()
const modrinthServersConsole = useModrinthServersConsole()
+const { canUsePowerActions, permissionDeniedMessage } = useServerPermissions()
watch(
() => props.showAdvancedDebugInfo,
@@ -107,6 +109,7 @@ const dismissCrash = () => {
provideConsoleManager({
logLines: modrinthServersConsole.output,
sendCommand: (cmd: string) => {
+ if (!canUsePowerActions.value) return
try {
client.archon.sockets.send(serverId, { event: 'command', cmd })
} catch (error) {
@@ -114,7 +117,12 @@ provideConsoleManager({
}
},
showCommandInput: true,
- disableCommandInput: computed(() => serverPowerState.value !== 'running'),
+ disableCommandInput: computed(
+ () => !canUsePowerActions.value || serverPowerState.value !== 'running',
+ ),
+ disableCommandInputTooltip: computed(() =>
+ canUsePowerActions.value ? undefined : permissionDeniedMessage.value,
+ ),
loading: computed(
() =>
!isConnected.value ||
@@ -122,6 +130,7 @@ provideConsoleManager({
isWsAuthIncorrect.value,
),
onClear: async () => {
+ if (!canUsePowerActions.value) return
modrinthServersConsole.clear()
try {
await client.kyros.logs_v1.clear()
@@ -129,6 +138,10 @@ provideConsoleManager({
console.error('Failed to clear server logs:', error)
}
},
+ clearDisabled: computed(() => !canUsePowerActions.value),
+ clearDisabledTooltip: computed(() =>
+ canUsePowerActions.value ? undefined : permissionDeniedMessage.value,
+ ),
shareDisabled: computed(() => !isConnected.value),
emptyStateType: 'server',
crashAnalysis,
diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue
index 36bf2a3e63..281d2f90e9 100644
--- a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue
+++ b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue
@@ -3,7 +3,7 @@
v-if="filteredNotices.length > 0"
class="relative mx-auto mb-4 flex w-full min-w-0 flex-col gap-3 px-6"
:class="{
- 'max-w-[1280px]': isNuxt,
+ 'max-w-[1280px]': constrainWidth,
}"
>
@@ -344,7 +344,7 @@
| |