feat: hosting access tab (#5995)

* feat: implement access tab with dummy data

* fix: spacing

* feat: qa

* feat: implement backend

* qa: qa pass

* feat: fix user "search"

* fix: lint

* feat: change to bitfield

* feat: fix fields

* fix: lint

* fix: lint

* feat: hook up api

* feat: fix permissions

* feat: audit log table event start

* feat: better mobile mode for audit log table

* feat: i18n

* feat: qa

* feat: enforce permissions

* feat: email template start

* feat: qa

* fix: tooltip bug

* feat: qa

* impl: sse support in api-client

* feat: sse impl

* fix: desync path

* feat: time frame picker from analytics

* feat: QA

* fix: spacing

* fix: permisison audit log entries

* fix: hosting manage page shared server detection

* fix: lint

* feat: qa + lint

* feat: audit log table sort by time

* feat: finish frontend panel stuff

* fix: lint

* fix: backend alignment

* fix: lint

* fix: supress friend errors

* feat: qa

* fix: qa

* fix: lint

* fix: utils barrel

* fix: safari cookies in dev

* fix: pin nuxt

* feat: fixes + notif fix

* fix: notifications

* feat: qa

* fix: notification sync not happening immediately

* fix: qa

* fix: qa

* feat: qa

* blog + prepr

* feat: toast shit

* blog images

* thumbnail update one last time

* prepr

* feat: use reinvite route

* update images

* fix: reinvite stuff

* fix: lint

* fix: alignment of save bar

* fix: notif sizing

* fix: split up access

* fix: lint

* fix: lint

* fix: link

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-06-04 15:58:01 +00:00
committed by GitHub
co-authored by Prospector
parent 58ad58f958
commit bd97ace974
227 changed files with 15578 additions and 2153 deletions
+16 -9
View File
@@ -3,9 +3,9 @@ import { LRUCache } from 'lru-cache'
import { injectI18n } from '../providers/i18n'
import { LOCALES } from './i18n.ts'
const formatterCache = new LRUCache<string, Intl.RelativeTimeFormat>({ max: 5 })
const formatterCache = new LRUCache<string, Intl.RelativeTimeFormat>({ 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
}
+1
View File
@@ -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'
@@ -23,7 +23,7 @@ export function useServerBackupsQueue(serverId: Ref<string>, worldId: Ref<string
enabled: computed(() => !!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
},
})
@@ -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<string>
worldId: ReadableRef<string | null>
server: ReadableRef<Archon.Servers.v0.Server | null | undefined>
serverFull?: ReadableRef<Archon.Servers.v1.ServerFull | null | undefined>
isSyncingContent: ReadableRef<boolean>
extraBusyReasons?: ComputedRef<BusyReason[]>
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<Archon.Websocket.v0.PowerState>('stopped')
const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>()
const isServerRunning = computed(() => serverPowerState.value === 'running')
const stats = ref<Stats>(createInitialStats())
const stats = ref<ServerStats>(createInitialStats())
const uptimeSeconds = ref(0)
const fsAuth = ref<{ url: string; token: string } | null>(null)
const fsOps = ref<Archon.Websocket.v0.FilesystemOperation[]>([])
@@ -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<string | null>,
server: options.server as Ref<Archon.Servers.v0.Server>,
serverFull,
currentUserPermissions,
isConnected,
isWsAuthIncorrect,
powerState: serverPowerState,
@@ -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<T> = Ref<T> | ComputedRef<T>
type SyncUnsubscriber = () => void
type UseServerPanelSyncOptions = {
serverId: ReadableRef<string>
worldId: ReadableRef<string | null>
}
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<typeof setTimeout> | 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<Archon.Servers.v0.Server>(
legacyServerDetailKey(serverId),
(current) =>
current
? {
...current,
name: event.name,
net: {
...current.net,
domain: event.subdomain,
},
}
: current,
)
queryClient.setQueryData<Archon.Servers.v1.ServerFull>(
serverV1DetailKey(serverId),
(current) =>
current
? {
...current,
name: event.name,
subdomain: event.subdomain,
}
: current,
)
}
function handleServerNetworkPatch(
serverId: string,
event: Archon.Sync.v1.ServerNetworkPatchEvent,
) {
queryClient.setQueryData<Archon.Servers.v0.Allocation[]>(
['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<Archon.Content.v1.Addons>(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<Archon.Content.v1.Addons>(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<Archon.Servers.v1.ServerFull>(
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,
}
}
@@ -0,0 +1,118 @@
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,
EXEC_COMMANDS: 1n << 61n,
FILES_WRITE: 1n << 60n,
SETUP: 1n << 59n,
BACKUPS: 1n << 58n,
ADVANCED: 1n << 57n,
RESET_SERVER: 1n << 56n,
MANAGE_USERS: 1n << 55n,
SUPPORT_AGENT: 1n,
INFRA_MANAGER: 1n << 1n,
INFRA_MANAGER_READ: 1n << 2n,
INFRA_SERVERS_XFER: 1n << 3n,
INFRA_USERS: 1n << 4n,
SERVER_ADMIN: ((1n << 64n) - 1n) ^ ((1n << 15n) - 1n),
} as const satisfies Record<ServerPermissionName, bigint>
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 canExecuteCommands = computed(() => hasCurrentUserPermission('EXEC_COMMANDS'))
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,
canExecuteCommands,
canWriteFiles,
canSetup,
canManageBackups,
canUseAdvancedSettings,
canResetServer,
canManageUsers,
permissionTooltip,
}
}