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
+2 -1
View File
@@ -1,8 +1,9 @@
export const useAffiliates = () => {
const config = useRuntimeConfig()
const affiliateCookie = useCookie('mrs_afl', {
maxAge: 60 * 60 * 24 * 7, // 7 days
sameSite: 'lax',
secure: true,
secure: config.public.cookieSecure,
httpOnly: false,
path: '/',
})
+2 -1
View File
@@ -30,10 +30,11 @@ export const initAuth = async (oldToken = null) => {
}
const route = useRoute()
const config = useRuntimeConfig()
const authCookie = useCookie('auth-token', {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: true,
secure: config.public.cookieSecure,
httpOnly: false,
path: '/',
})
+12 -9
View File
@@ -54,6 +54,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
showViewProdRouteBanner: false,
showModeratorProjectMemberUi: false,
showModeratorPrivateMessageHighlight: true,
archonApiStaging: false,
showHostingAccessInstanceAuditLog: false,
} as const)
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
@@ -64,19 +66,20 @@ export type AllFeatureFlags = {
export type PartialFeatureFlags = Partial<AllFeatureFlags>
const COOKIE_OPTIONS = {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: true,
httpOnly: false,
path: '/',
} satisfies CookieOptions<PartialFeatureFlags>
const getCookieOptions = () =>
({
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: useRuntimeConfig().public.cookieSecure,
httpOnly: false,
path: '/',
}) satisfies CookieOptions<PartialFeatureFlags>
export const useFeatureFlags = () =>
useState<AllFeatureFlags>('featureFlags', () => {
const config = useRuntimeConfig()
const savedFlags = useCookie<PartialFeatureFlags>('featureFlags', COOKIE_OPTIONS)
const savedFlags = useCookie<PartialFeatureFlags>('featureFlags', getCookieOptions())
if (!savedFlags.value) {
savedFlags.value = {}
@@ -106,6 +109,6 @@ export const useFeatureFlags = () =>
export const saveFeatureFlags = () => {
const flags = useFeatureFlags()
const cookie = useCookie<PartialFeatureFlags>('featureFlags', COOKIE_OPTIONS)
const cookie = useCookie<PartialFeatureFlags>('featureFlags', getCookieOptions())
cookie.value = flags.value
}
@@ -1,264 +0,0 @@
/**
* @deprecated Use `@modrinth/api-client` via `injectModrinthClient()` instead.
* The api-client's archon modules (`client.archon.servers_v0`, etc.) handle auth,
* retry, and circuit breaking automatically. This composable is kept for legacy
* code that hasn't been migrated yet.
*/
import { PANEL_VERSION } from '@modrinth/api-client'
import type { V1ErrorInfo } from '@modrinth/utils'
import { ModrinthServerError, ModrinthServersFetchError } from '@modrinth/utils'
import { $fetch, FetchError } from 'ofetch'
export interface ServersFetchOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
contentType?: string
body?: Record<string, any>
version?: number | 'internal'
override?: {
url?: string
token?: string
}
retry?: number | boolean
bypassAuth?: boolean
}
export async function useServersFetch<T>(
path: string,
options: ServersFetchOptions = {},
module?: string,
errorContext?: string,
): Promise<T> {
const config = useRuntimeConfig()
const auth = await useAuth()
const authToken = auth.value?.token
if (!authToken && !options.bypassAuth) {
const error = new ModrinthServersFetchError(
'[Modrinth Hosting] Cannot fetch without auth',
10000,
)
throw new ModrinthServerError('Missing auth token', 401, error, module, undefined, undefined)
}
const {
method = 'GET',
contentType = 'application/json',
body,
version = 0,
override,
retry = method === 'GET' ? 3 : 0,
} = options
const circuitBreakerKey = `${module || 'default'}_${path}`
const failureCount = useState<number>(`fetch_failures_${circuitBreakerKey}`, () => 0)
const lastFailureTime = useState<number>(`last_failure_${circuitBreakerKey}`, () => 0)
const now = Date.now()
if (failureCount.value >= 3 && now - lastFailureTime.value < 30000) {
const error = new ModrinthServersFetchError(
'[Modrinth Hosting] Circuit breaker open - too many recent failures',
503,
)
throw new ModrinthServerError(
'Service temporarily unavailable',
503,
error,
module,
undefined,
undefined,
)
}
if (now - lastFailureTime.value > 30000) {
failureCount.value = 0
}
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
/\/$/,
'',
)
if (!base) {
const error = new ModrinthServersFetchError(
'[Modrinth Hosting] Cannot fetch without base url. Make sure to set a PYRO_BASE_URL in environment variables',
10001,
)
throw new ModrinthServerError(
'Configuration error: Missing PYRO_BASE_URL',
500,
error,
module,
undefined,
undefined,
)
}
const versionString = `v${version}`
let newOverrideUrl = override?.url
if (newOverrideUrl && newOverrideUrl.includes('v0') && version !== 0) {
newOverrideUrl = newOverrideUrl.replace('v0', versionString)
}
const fullUrl = newOverrideUrl
? `https://${newOverrideUrl}/${path.replace(/^\//, '')}`
: version === 0
? `${base}/modrinth/v${version}/${path.replace(/^\//, '')}`
: version === 'internal'
? `${base}/_internal/${path.replace(/^\//, '')}`
: `${base}/v${version}/${path.replace(/^\//, '')}`
const headers: Record<string, string> = {
'User-Agent': 'Modrinth/1.0 (https://modrinth.com)',
'X-Archon-Request': 'true',
'X-Panel-Version': String(PANEL_VERSION),
Vary: 'Accept, Origin',
}
if (!options.bypassAuth) {
headers.Authorization = `Bearer ${override?.token ?? authToken}`
headers['Access-Control-Allow-Headers'] = 'Authorization'
}
if (contentType !== 'none') {
headers['Content-Type'] = contentType
}
if (import.meta.client && typeof window !== 'undefined') {
headers.Origin = window.location.origin
}
let attempts = 0
const maxAttempts = (typeof retry === 'boolean' ? (retry ? 3 : 1) : retry) + 1
let lastError: Error | null = null
while (attempts < maxAttempts) {
try {
const response = await $fetch<T>(fullUrl, {
method,
headers,
body:
body && contentType === 'application/json' ? JSON.stringify(body) : (body ?? undefined),
timeout: 10000,
})
failureCount.value = 0
return response
} catch (error) {
lastError = error as Error
attempts++
if (error instanceof FetchError) {
const statusCode = error.response?.status
const statusText = error.response?.statusText || 'Unknown error'
if (statusCode && statusCode >= 500) {
failureCount.value++
lastFailureTime.value = now
}
let v1Error: V1ErrorInfo | undefined
if (error.data?.error && error.data?.description) {
v1Error = {
context: errorContext,
...error.data,
}
}
const errorMessages: { [key: number]: string } = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
408: 'Request Timeout',
429: "You're making requests too quickly. Please wait a moment and try again.",
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout',
}
const message =
statusCode && statusCode in errorMessages
? errorMessages[statusCode]
: `HTTP Error: ${statusCode || 'unknown'} ${statusText}`
const isRetryable = statusCode ? [408, 429].includes(statusCode) : false
const is5xxRetryable =
statusCode && statusCode >= 500 && statusCode < 600 && method === 'GET' && attempts === 1
if (!(isRetryable || is5xxRetryable) || attempts >= maxAttempts) {
console.error('Fetch error:', error)
const fetchError = new ModrinthServersFetchError(
`[Modrinth Hosting] ${error.message}`,
statusCode,
error,
)
throw new ModrinthServerError(
`[Modrinth Hosting] ${message}`,
statusCode,
fetchError,
module,
v1Error,
error.data,
)
}
const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000
const delay = Math.min(baseDelay * Math.pow(2, attempts - 1) + Math.random() * 1000, 15000)
console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`)
await new Promise((resolve) => setTimeout(resolve, delay))
continue
}
console.error('Unexpected fetch error:', error)
const fetchError = new ModrinthServersFetchError(
'[Modrinth Hosting] An unexpected error occurred during the fetch operation.',
undefined,
error as Error,
)
throw new ModrinthServerError(
'Unexpected error during fetch operation',
undefined,
fetchError,
module,
undefined,
undefined,
)
}
}
console.error('All retry attempts failed:', lastError)
if (lastError instanceof FetchError) {
const statusCode = lastError.response?.status
const pyroError = new ModrinthServersFetchError(
'Maximum retry attempts reached',
statusCode,
lastError,
)
throw new ModrinthServerError(
'Maximum retry attempts reached',
statusCode,
pyroError,
module,
undefined,
lastError.data,
)
}
const fetchError = new ModrinthServersFetchError(
'Maximum retry attempts reached',
undefined,
lastError || undefined,
)
throw new ModrinthServerError(
'Maximum retry attempts reached',
undefined,
fetchError,
module,
undefined,
undefined,
)
}
@@ -17,7 +17,6 @@ export type FilterSelection = {
const cookieDefaults = {
maxAge: TEN_MINUTES,
sameSite: 'lax' as const,
secure: true,
path: '/',
httpOnly: false,
}
@@ -52,13 +51,19 @@ function newFilterSelection(
}
export function useCdnDownloadContext() {
const filterGameVersionCookie = useCookie<string | null>('mr_download_filter_game_version', {
const config = useRuntimeConfig()
const cookieOptions = {
...cookieDefaults,
secure: config.public.cookieSecure,
}
const filterGameVersionCookie = useCookie<string | null>('mr_download_filter_game_version', {
...cookieOptions,
default: () => null,
})
const filterLoaderCookie = useCookie<string | null>('mr_download_filter_loader', {
...cookieDefaults,
...cookieOptions,
default: () => null,
})