@@ -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..e04aef1e28
--- /dev/null
+++ b/packages/ui/src/components/servers/access/AuditLogEventCell.vue
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
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..61f380a575
--- /dev/null
+++ b/packages/ui/src/components/servers/access/AuditLogTable.vue
@@ -0,0 +1,703 @@
+
+
+
+
+
+
+
+
+
+ {{ 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..5738a19143
--- /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..c058daae2e
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/AddonEvent.vue
@@ -0,0 +1,89 @@
+
+
+
+ {{ 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..0f90585fef
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/BackupEvent.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..97fcbc4143
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/BaseEvent.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
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..b5361df243
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/ConfigEvent.vue
@@ -0,0 +1,142 @@
+
+
+
+ {{ formatMessage(messages.propertiesModifiedLabel) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..c55124461f
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/ConsoleEvent.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
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..d92dfe9026
--- /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..be005e7faf
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/EventEntityList.vue
@@ -0,0 +1,139 @@
+
+
+
+
+ ,
+
+
+
+ {{ formatMessage(messages.hiddenCount, { count: hiddenCount }) }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/servers/access/events/EventInlineText.vue b/packages/ui/src/components/servers/access/events/EventInlineText.vue
new file mode 100644
index 0000000000..5337534be3
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/EventInlineText.vue
@@ -0,0 +1,23 @@
+
+
+ {{ text }}
+
+
+
+
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..0cc5db87d7
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/ModpackEvent.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..2e82c38542
--- /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/ServerMetaEvent.vue b/packages/ui/src/components/servers/access/events/ServerMetaEvent.vue
new file mode 100644
index 0000000000..3782d3c623
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/ServerMetaEvent.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..59effd1e19
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/UnknownEvent.vue
@@ -0,0 +1,25 @@
+
+
+ {{ formatMessage(messages.unknownEvent) }}
+
+
+
+
+
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..755c1353ed
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/UserAccessEvent.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..5c1e132022
--- /dev/null
+++ b/packages/ui/src/components/servers/access/events/index.ts
@@ -0,0 +1,16 @@
+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 ServerMetaEvent } from './ServerMetaEvent.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)
{
{
| |