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
@@ -133,6 +133,7 @@ import {
type PopupNotificationProgressItem,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { Dropdown } from 'floating-vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -284,6 +285,7 @@ function goToTerminal(path?: string) {
}
const currentLoadingBars = ref<LoadingBar[]>([])
const currentLoadingBarIconUrls = ref<Record<string, string | null>>({})
const notificationId = ref<string | number | null>(null)
const dismissed = ref(false)
@@ -303,6 +305,16 @@ function getLoadingText(loadingBar: LoadingBar): string {
return loadingBar.message ? `${percent}% ${loadingBar.message}` : `${percent}%`
}
function getDisplayIconUrl(icon: string | null | undefined): string | null {
if (!icon) {
return null
}
if (/^(https?:|data:|blob:|asset:|tauri:)/.test(icon)) {
return icon
}
return convertFileSrc(icon)
}
function getNotification(): PopupNotification | null {
if (!notificationId.value) {
return null
@@ -326,6 +338,7 @@ function buildDownloadItems(): PopupNotificationProgressItem[] {
id: getLoadingBarKey(bar),
title: bar.title ?? '',
text: getLoadingText(bar),
iconUrl: currentLoadingBarIconUrls.value[getLoadingBarKey(bar)] ?? null,
progress: getLoadingProgress(bar),
waiting: !bar.total || bar.total <= 0,
}))
@@ -400,6 +413,32 @@ async function refreshLoadingBars() {
.map(formatLoadingBars)
.filter((bar) => bar?.bar_type?.type !== 'launcher_update')
const profilePaths = Array.from(
new Set(
currentLoadingBars.value
.map((bar) => bar.bar_type?.profile_path)
.filter((path): path is string => !!path),
),
)
const profiles = profilePaths.length
? await getInstances(profilePaths).catch((error) => {
handleError(error)
return []
})
: []
const profileIconUrls = new Map(
profiles.map((profile) => [profile.path, getDisplayIconUrl(profile.icon_path)]),
)
currentLoadingBarIconUrls.value = Object.fromEntries(
currentLoadingBars.value.map((bar) => {
const barIconUrl = getDisplayIconUrl(bar.bar_type?.icon)
const profileIconUrl = bar.bar_type?.profile_path
? profileIconUrls.get(bar.bar_type.profile_path)
: null
return [getLoadingBarKey(bar), barIconUrl ?? profileIconUrl ?? null]
}),
)
currentLoadingBars.value.sort((a, b) => {
const aKey = `${a.loading_bar_uuid ?? a.id ?? ''}`
const bKey = `${b.loading_bar_uuid ?? b.id ?? ''}`
@@ -8,6 +8,7 @@ import {
InstallationSettingsLayout,
provideAppBackup,
provideInstallationSettings,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
@@ -34,9 +35,17 @@ import type { Manifest } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const debug = useDebugLogger('AppInstallationSettings')
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
debug('metadata load: start', {
instancePath: instance.value.path,
loader: instance.value.loader,
gameVersion: instance.value.game_version,
installStage: instance.value.install_stage,
})
const [
fabric_versions,
forge_versions,
@@ -72,6 +81,15 @@ const [
.catch(handleError),
])
debug('metadata load: done', {
hasFabricManifest: !!fabric_versions?.value,
hasForgeManifest: !!forge_versions?.value,
hasQuiltManifest: !!quilt_versions?.value,
hasNeoforgeManifest: !!neoforge_versions?.value,
gameVersions: all_game_versions?.value?.length ?? 0,
availablePlatforms: loaders?.value?.map((loader) => loader.name) ?? [],
})
const { data: modpackInfo } = useQuery({
queryKey: computed(() => ['linkedModpackInfo', instance.value.path]),
queryFn: () => get_linked_modpack_info(instance.value.path, 'must_revalidate'),
@@ -95,11 +113,21 @@ function getManifest(loader: string) {
quilt: quilt_versions,
neoforge: neoforge_versions,
}
return map[loader]
const manifest = map[loader]
debug('getManifest:', {
loader,
hasManifest: !!manifest?.value,
gameVersions: manifest?.value?.gameVersions?.length ?? 0,
})
return manifest
}
provideAppBackup({
async createBackup() {
debug('createBackup: start', {
instancePath: instance.value.path,
instanceName: instance.value.name,
})
const allProfiles = await list()
const prefix = `${instance.value.name} - Backup #`
const existingNums = allProfiles
@@ -109,6 +137,7 @@ provideAppBackup({
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
const newPath = await duplicate(instance.value.path)
await edit(newPath, { name: `${prefix}${nextNum}` })
debug('createBackup: done', { newPath, backupName: `${prefix}${nextNum}` })
},
})
@@ -165,32 +194,72 @@ provideInstallationSettings({
const manifest = getManifest(loader)
return !!manifest?.value?.gameVersions?.some((x) => item.version === x.id)
})
return (showSnapshots ? filtered : filtered.filter((x) => x.version_type === 'release')).map(
(x) => ({ value: x.version, label: x.version }),
)
const result = (
showSnapshots ? filtered : filtered.filter((x) => x.version_type === 'release')
).map((x) => ({ value: x.version, label: x.version }))
debug('resolveGameVersions:', {
loader,
showSnapshots,
totalVersions: versions.length,
filteredVersions: filtered.length,
resultVersions: result.length,
})
return result
},
resolveLoaderVersions(loader, gameVersion) {
if (loader === 'vanilla' || !gameVersion) return []
const manifest = getManifest(loader)
if (!manifest?.value) return []
if (loader === 'fabric' || loader === 'quilt') {
return manifest.value.gameVersions[0]?.loaders ?? []
if (loader === 'vanilla' || !gameVersion) {
debug('resolveLoaderVersions: skipped', { loader, gameVersion })
return []
}
return manifest.value.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
const manifest = getManifest(loader)
if (!manifest?.value) {
debug('resolveLoaderVersions: no manifest', { loader, gameVersion })
return []
}
if (loader === 'fabric' || loader === 'quilt') {
const result = manifest.value.gameVersions[0]?.loaders ?? []
debug('resolveLoaderVersions: fabric/quilt result', {
loader,
gameVersion,
count: result.length,
})
return result
}
const result =
manifest.value.gameVersions?.find((item) => item.id === gameVersion)?.loaders ?? []
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
return result
},
resolveHasSnapshots(loader) {
const versions = all_game_versions?.value ?? []
if (loader === 'vanilla') return versions.some((x) => x.version_type !== 'release')
if (loader === 'vanilla') {
const result = versions.some((x) => x.version_type !== 'release')
debug('resolveHasSnapshots: vanilla', { loader, result })
return result
}
const manifest = getManifest(loader)
const supported = versions.filter(
(item) => !!manifest?.value?.gameVersions?.some((x) => item.version === x.id),
)
return supported.some((x) => x.version_type !== 'release')
const result = supported.some((x) => x.version_type !== 'release')
debug('resolveHasSnapshots:', {
loader,
totalVersions: versions.length,
supportedVersions: supported.length,
result,
})
return result
},
async save(platform, gameVersion, loaderVersionId) {
debug('save: called', {
instancePath: instance.value.path,
platform,
gameVersion,
loaderVersionId,
})
const editProfile: Record<string, string | undefined> = {
loader: platform,
game_version: gameVersion,
@@ -199,17 +268,21 @@ provideInstallationSettings({
editProfile.loader_version = loaderVersionId
}
await edit(instance.value.path, editProfile).catch(handleError)
debug('save: edit complete', { editProfile })
},
afterSave: async () => {
debug('afterSave: installing', { instancePath: instance.value.path })
await install(instance.value.path, false).catch(handleError)
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
debug('afterSave: done')
},
async repair() {
debug('repair: called', { instancePath: instance.value.path })
repairing.value = true
await install(instance.value.path, true).catch(handleError)
repairing.value = false
@@ -217,9 +290,11 @@ provideInstallationSettings({
loader: instance.value.loader,
game_version: instance.value.game_version,
})
debug('repair: done')
},
async reinstallModpack() {
debug('reinstallModpack: called', { instancePath: instance.value.path })
reinstalling.value = true
await update_repair_modrinth(instance.value.path).catch(handleError)
reinstalling.value = false
@@ -227,9 +302,11 @@ provideInstallationSettings({
loader: instance.value.loader,
game_version: instance.value.game_version,
})
debug('reinstallModpack: done')
},
async unlinkModpack() {
debug('unlinkModpack: called', { instancePath: instance.value.path })
await edit(instance.value.path, {
linked_data: null as unknown as undefined,
})
@@ -237,27 +314,38 @@ provideInstallationSettings({
queryKey: ['linkedModpackInfo', instance.value.path],
})
onUnlinked()
debug('unlinkModpack: done')
},
getCachedModpackVersions: () => null,
async fetchModpackVersions() {
debug('fetchModpackVersions: called', {
projectId: instance.value.linked_data?.project_id,
})
const versions = await get_project_versions(instance.value.linked_data!.project_id!).catch(
handleError,
)
debug('fetchModpackVersions: done', { count: versions?.length ?? 0 })
return (versions ?? []) as Labrinth.Versions.v2.Version[]
},
async getVersionChangelog(versionId: string) {
debug('getVersionChangelog: called', { versionId })
return (await get_version(versionId, 'must_revalidate').catch(
() => null,
)) as Labrinth.Versions.v2.Version | null
},
async onModpackVersionConfirm(version) {
debug('onModpackVersionConfirm: called', {
versionId: version.id,
instancePath: instance.value.path,
})
await update_managed_modrinth_version(instance.value.path, version.id)
await queryClient.invalidateQueries({
queryKey: ['linkedModpackInfo', instance.value.path],
})
debug('onModpackVersionConfirm: done')
},
updaterModalProps: computed(() => ({
@@ -1,52 +0,0 @@
<template>
<div class="flex flex-wrap items-center gap-x-1.5 gap-y-1 leading-snug text-primary">
<button
v-if="inviterName"
type="button"
class="inline-flex min-w-0 items-center border-0 bg-transparent p-0 font-semibold text-contrast hover:underline"
@click="openInviterProfile(inviterName)"
>
<Avatar
:src="inviterAvatarUrl"
:alt="inviterName"
circle
size="xxs"
no-shadow
class="mr-1.5 inline-flex"
/>
<span>{{ inviterName }}</span>
</button>
<span>
<span v-if="inviterName" class="whitespace-nowrap">has invited you to manage</span>
<span v-else class="whitespace-nowrap">You have been invited to manage</span>
<span class="font-semibold text-contrast ml-1">{{ serverName }}</span>
<span>.</span>
</span>
</div>
</template>
<script setup>
import { Avatar } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { config } from '@/config'
defineProps({
inviterName: {
type: String,
default: null,
},
inviterAvatarUrl: {
type: String,
default: null,
},
serverName: {
type: String,
required: true,
},
})
function openInviterProfile(username) {
openUrl(`${config.siteUrl}/user/${encodeURIComponent(username)}`)
}
</script>