diff --git a/apps/app-frontend/src/pages/hosting/manage/Access.vue b/apps/app-frontend/src/pages/hosting/manage/Access.vue new file mode 100644 index 0000000000..59a3b82e36 --- /dev/null +++ b/apps/app-frontend/src/pages/hosting/manage/Access.vue @@ -0,0 +1,33 @@ + + + diff --git a/apps/app-frontend/src/pages/hosting/manage/index.js b/apps/app-frontend/src/pages/hosting/manage/index.js index 50052e3f9e..0c10b07133 100644 --- a/apps/app-frontend/src/pages/hosting/manage/index.js +++ b/apps/app-frontend/src/pages/hosting/manage/index.js @@ -1,7 +1,8 @@ +import Access from './Access.vue' import Backups from './Backups.vue' import Content from './Content.vue' import Files from './Files.vue' import Index from './Index.vue' import Overview from './Overview.vue' -export { Backups, Content, Files, Index, Overview } +export { Access, Backups, Content, Files, Index, Overview } diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js index c12306b5ad..2057d67e17 100644 --- a/apps/app-frontend/src/routes.js +++ b/apps/app-frontend/src/routes.js @@ -73,6 +73,14 @@ export default new createRouter({ breadcrumb: [{ name: '?Server' }], }, }, + { + path: 'access', + name: 'ServerManageAccess', + component: Hosting.Access, + meta: { + breadcrumb: [{ name: '?Server' }], + }, + }, ], }, { diff --git a/apps/frontend/CLAUDE.md b/apps/frontend/CLAUDE.md index 9d0d05c4df..03515cbb31 100644 --- a/apps/frontend/CLAUDE.md +++ b/apps/frontend/CLAUDE.md @@ -40,4 +40,3 @@ These composables are deprecated and should not be used in new code: - **`useAsyncData`** - we use tanstack, not nuxt's built in async data utility. - **`useBaseFetch`** (`src/composables/fetch.js`) — legacy Labrinth fetch wrapper. Use `client.labrinth.*` modules instead. -- **`useServersFetch`** (`src/composables/servers/servers-fetch.ts`) — legacy Archon fetch wrapper with manual retry/circuit-breaker. Use `client.archon.*` modules instead — refer to the `packages/api-client/CLAUDE.md` for more information. diff --git a/apps/frontend/src/components/ui/admin/AssignNoticeModal.vue b/apps/frontend/src/components/ui/admin/AssignNoticeModal.vue index 4a303112a0..a52bb5c01e 100644 --- a/apps/frontend/src/components/ui/admin/AssignNoticeModal.vue +++ b/apps/frontend/src/components/ui/admin/AssignNoticeModal.vue @@ -1,20 +1,22 @@ + + diff --git a/packages/api-client/src/core/abstract-client.ts b/packages/api-client/src/core/abstract-client.ts index 97ce38dd1b..7bcef2a215 100644 --- a/packages/api-client/src/core/abstract-client.ts +++ b/packages/api-client/src/core/abstract-client.ts @@ -1,6 +1,6 @@ import type { InferredClientModules } from '../modules' import { buildModuleStructure } from '../modules' -import type { ClientConfig } from '../types/client' +import type { BaseUrlConfig, ClientConfig } from '../types/client' import type { RequestContext, RequestOptions } from '../types/request' import type { UploadMetadata, UploadProgress, UploadRequestOptions } from '../types/upload' import type { AbstractFeature } from './abstract-feature' @@ -116,9 +116,9 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { async request(path: string, options: RequestOptions): Promise { let baseUrl: string if (options.api === 'labrinth') { - baseUrl = this.config.labrinthBaseUrl! + baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!) } else if (options.api === 'archon') { - baseUrl = this.config.archonBaseUrl! + baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!) } else { baseUrl = options.api } @@ -243,6 +243,10 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { return `${base}${versionPath}${cleanPath}` } + protected resolveBaseUrl(baseUrl: BaseUrlConfig): string { + return typeof baseUrl === 'function' ? baseUrl() : baseUrl + } + /** * Build the request context */ diff --git a/packages/api-client/src/modules/archon/nodes/internal.ts b/packages/api-client/src/modules/archon/nodes/internal.ts new file mode 100644 index 0000000000..254f392c8c --- /dev/null +++ b/packages/api-client/src/modules/archon/nodes/internal.ts @@ -0,0 +1,20 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonNodesInternalModule extends AbstractModule { + public getModuleID(): string { + return 'archon_nodes_internal' + } + + /** + * Get node hostnames and region summary for admin tooling. + * GET /_internal/nodes/overview + */ + public async overview(): Promise { + return this.client.request('/nodes/overview', { + api: 'archon', + version: 'internal', + method: 'GET', + }) + } +} diff --git a/packages/api-client/src/modules/archon/notices/v0.ts b/packages/api-client/src/modules/archon/notices/v0.ts new file mode 100644 index 0000000000..a6e76b0277 --- /dev/null +++ b/packages/api-client/src/modules/archon/notices/v0.ts @@ -0,0 +1,98 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonNoticesV0Module extends AbstractModule { + public getModuleID(): string { + return 'archon_notices_v0' + } + + /** + * Get all server notices. + * GET /modrinth/v0/notices + */ + public async list(): Promise { + return this.client.request('/notices', { + api: 'archon', + version: 'modrinth/v0', + method: 'GET', + }) + } + + /** + * Create a server notice. + * POST /modrinth/v0/notices + */ + public async create( + request: Archon.Notices.v0.Announce, + ): Promise { + return this.client.request('/notices', { + api: 'archon', + version: 'modrinth/v0', + method: 'POST', + body: request, + }) + } + + /** + * Update a server notice. + * PATCH /modrinth/v0/notices/:id + */ + public async update(id: number, request: Archon.Notices.v0.AnnouncePatch): Promise { + await this.client.request(`/notices/${id}`, { + api: 'archon', + version: 'modrinth/v0', + method: 'PATCH', + body: request, + }) + } + + /** + * Delete a server notice. + * DELETE /modrinth/v0/notices/:id + */ + public async delete(id: number): Promise { + await this.client.request(`/notices/${id}`, { + api: 'archon', + version: 'modrinth/v0', + method: 'DELETE', + }) + } + + /** + * Assign a notice to a server or node. + * PUT /modrinth/v0/notices/:id/assign?server=:serverId + * PUT /modrinth/v0/notices/:id/assign?node=:nodeId + */ + public async assign(id: number, target: Archon.Notices.v0.AssignmentTarget): Promise { + await this.client.request(`/notices/${id}/assign`, { + api: 'archon', + version: 'modrinth/v0', + method: 'PUT', + params: this.assignmentTargetToParams(target), + }) + } + + /** + * Unassign a notice from a server or node. + * PUT /modrinth/v0/notices/:id/unassign?server=:serverId + * PUT /modrinth/v0/notices/:id/unassign?node=:nodeId + */ + public async unassign(id: number, target: Archon.Notices.v0.AssignmentTarget): Promise { + await this.client.request(`/notices/${id}/unassign`, { + api: 'archon', + version: 'modrinth/v0', + method: 'PUT', + params: this.assignmentTargetToParams(target), + }) + } + + private assignmentTargetToParams( + target: Archon.Notices.v0.AssignmentTarget, + ): Record { + if ('server' in target) { + return { server: target.server } + } + + return { node: target.node } + } +} diff --git a/packages/api-client/src/modules/archon/server-users/v1.ts b/packages/api-client/src/modules/archon/server-users/v1.ts new file mode 100644 index 0000000000..1cacbc41eb --- /dev/null +++ b/packages/api-client/src/modules/archon/server-users/v1.ts @@ -0,0 +1,65 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonServerUsersV1Module extends AbstractModule { + public getModuleID(): string { + return 'archon_server_users_v1' + } + + /** + * Get list of users with access to a server + * GET /v1/servers/:server_id/users + */ + public async list(serverId: string): Promise { + return this.client.request(`/servers/${serverId}/users`, { + api: 'archon', + version: 1, + method: 'GET', + }) + } + + /** + * Add a user to a server + * POST /v1/servers/:server_id/users + */ + public async add( + serverId: string, + user: Archon.ServerUsers.v1.AddServerUserRequest, + ): Promise { + await this.client.request(`/servers/${serverId}/users`, { + api: 'archon', + version: 1, + method: 'POST', + body: user, + }) + } + + /** + * Remove a user from a server + * DELETE /v1/servers/:server_id/users/:user_id + */ + public async delete(serverId: string, userId: string): Promise { + await this.client.request(`/servers/${serverId}/users/${userId}`, { + api: 'archon', + version: 1, + method: 'DELETE', + }) + } + + /** + * Update a user's server role + * PATCH /v1/servers/:server_id/users/:user_id + */ + public async update( + serverId: string, + userId: string, + role: Archon.ServerUsers.v1.ServerUserRole, + ): Promise { + await this.client.request(`/servers/${serverId}/users/${userId}`, { + api: 'archon', + version: 1, + method: 'PATCH', + body: JSON.stringify(role), + }) + } +} diff --git a/packages/api-client/src/modules/archon/transfers/internal.ts b/packages/api-client/src/modules/archon/transfers/internal.ts new file mode 100644 index 0000000000..ccbedaffd8 --- /dev/null +++ b/packages/api-client/src/modules/archon/transfers/internal.ts @@ -0,0 +1,84 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Archon } from '../types' + +export class ArchonTransfersInternalModule extends AbstractModule { + public getModuleID(): string { + return 'archon_transfers_internal' + } + + /** + * Schedule transfers for specific servers. + * POST /_internal/transfers/schedule/servers + */ + public async scheduleServers( + request: Archon.Transfers.Internal.ScheduleServerTransfersRequest, + ): Promise { + return this.client.request( + '/transfers/schedule/servers', + { + api: 'archon', + version: 'internal', + method: 'POST', + body: request, + }, + ) + } + + /** + * Schedule transfers for all servers on specific nodes. + * POST /_internal/transfers/schedule/nodes + */ + public async scheduleNodes( + request: Archon.Transfers.Internal.ScheduleNodeTransfersRequest, + ): Promise { + return this.client.request( + '/transfers/schedule/nodes', + { + api: 'archon', + version: 'internal', + method: 'POST', + body: request, + }, + ) + } + + /** + * Get transfer batch history. + * GET /_internal/transfers/history + */ + public async history( + options?: Archon.Transfers.Internal.TransferHistoryQuery, + ): Promise { + const params: Record = {} + if (options?.page !== undefined) params.page = options.page + if (options?.page_size !== undefined) params.page_size = options.page_size + + return this.client.request( + '/transfers/history', + { + api: 'archon', + version: 'internal', + method: 'GET', + params, + }, + ) + } + + /** + * Cancel pending transfer batches. + * POST /_internal/transfers/cancel + */ + public async cancel( + request: Archon.Transfers.Internal.CancelTransfersRequest, + ): Promise { + return this.client.request( + '/transfers/cancel', + { + api: 'archon', + version: 'internal', + method: 'POST', + body: request, + }, + ) + } +} diff --git a/packages/api-client/src/modules/archon/types.ts b/packages/api-client/src/modules/archon/types.ts index b6435c0be2..610031c46c 100644 --- a/packages/api-client/src/modules/archon/types.ts +++ b/packages/api-client/src/modules/archon/types.ts @@ -1,6 +1,169 @@ import type { Labrinth } from '../labrinth/types' export namespace Archon { + export namespace Nodes { + export namespace Internal { + export type Node = { + id: string + hostname: string + region: string + created_at: string | null + locked: boolean + } + + export type Server = { + id: string + available: boolean + } + + export type NodeFull = Node & { + servers: Server[] + } + + export type Overview = { + node_hostnames: string[] + regions: Region[] + total_servers_active: number + } + + export type Region = { + display_name: string + country_code: string + key: string + server_count: number + node_count: number + } + + export type RegionWithStatistics = { + region: Region + active_servers: string[] + } + } + } + + export namespace Notices { + export namespace v0 { + export type Notice = { + id: number + dismissable: boolean + title: string | null + message: string + level: string + announced: string + } + + export type ListedNotice = { + id: number + dismissable: boolean + message: string + title: string | null + level: string + announce_at: string + expires: string | null + assigned: Assignment[] + dismissed_by: Dismisser[] + } + + export type Dismisser = { + server: string + dismissed_on: string + } + + export type Assignment = { + kind: string + id: string + name: string + } + + export type AssignmentTarget = { server: string } | { node: string } + + export type Announce = { + message: string + title?: string | null + level: string + dismissable: boolean + announce_at: string + expires?: string | null + } + + export type AnnouncePatch = { + message?: string + title?: string | null + level?: string + dismissable?: boolean + announce_at?: string + expires?: string | null + } + + export type PostNoticeResponseBody = { + id: number + } + } + } + + export namespace Transfers { + export namespace Internal { + export type ProvisionOptions = { + region?: string | null + node_tags: string[] + } + + export type ScheduleServerTransfersRequest = { + server_ids: string[] + scheduled_at?: string | null + target_region?: string | null + node_tags?: string[] + reason?: string | null + } + + export type ScheduleNodeTransfersRequest = { + node_hostnames: string[] + scheduled_at?: string | null + target_region?: string | null + node_tags?: string[] + reason?: string | null + cordon_nodes?: boolean + tag_nodes?: string | null + } + + export type ScheduleTransfersResponse = { + batch_id: number + scheduled_count: number + } + + export type CancelTransfersRequest = { + batch_ids: number[] + } + + export type CancelTransfersResponse = { + cancelled_count: number + } + + export type TransferLogBatchEntry = { + id: number + created_by: string + created_at: string + reason?: string | null + scheduled_at: string + cancelled: boolean + log_count: number + provision_options: ProvisionOptions + } + + export type TransferHistoryQuery = { + page?: number + page_size?: number + } + + export type TransferHistoryResponse = { + batches: TransferLogBatchEntry[] + total: number + page: number + page_size: number + } + } + } + export namespace Content { export namespace v1 { export type AddonKind = 'mod' | 'plugin' | 'datapack' | 'shader' | 'resourcepack' @@ -220,6 +383,55 @@ export namespace Archon { } } + export namespace ServerUsers { + export namespace v1 { + export type ServerUserRole = 'Owner' | 'Editor' | 'Viewer' | 'Unknown' + + export type AssignableServerUserRole = Exclude + + export enum UserScope { + NONE = 0, + + SERVER_ADMIN = -0x8000, + EDITOR = -0x400000000000000, + VIEWER = -0x4000000000000000, + RESERVED_BITS = 0x7fff, + + BASE_READ = -0x8000000000000000, + POWER_ACTIONS = 0x4000000000000000, + FILES_WRITE = 0x2000000000000000, + SETUP = 0x1000000000000000, + BACKUPS = 0x800000000000000, + ADVANCED = 0x400000000000000, + RESET_SERVER = 0x200000000000000, + MANAGE_USERS = 0x100000000000000, + + SUPPORT_AGENT = 0x1, + INFRA_MANAGER = 0x2, + INFRA_MANAGER_READ = 0x4, + INFRA_SERVERS_XFER = 0x8, + } + + export type UserResp = { + username: string + avatar_url: string + } + + export type ServerUser = { + user: UserResp + added_on?: string | null + permissions: UserScope + } + + export type AddServerUserRequest = { + server_id?: string | null + user_id: string + added_on?: string | null + role: ServerUserRole + } + } + } + export namespace Servers { export namespace v0 { export type ServerGetResponse = { diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts index 7219d12e55..c18dbbe9fa 100644 --- a/packages/api-client/src/modules/index.ts +++ b/packages/api-client/src/modules/index.ts @@ -3,10 +3,14 @@ import type { AbstractModule } from '../core/abstract-module' import { ArchonBackupsV1Module } from './archon/backups/v1' import { ArchonBackupsQueueV1Module } from './archon/backups-queue/v1' import { ArchonContentV1Module } from './archon/content/v1' +import { ArchonNodesInternalModule } from './archon/nodes/internal' +import { ArchonNoticesV0Module } from './archon/notices/v0' import { ArchonOptionsV1Module } from './archon/options/v1' import { ArchonPropertiesV1Module } from './archon/properties/v1' +import { ArchonServerUsersV1Module } from './archon/server-users/v1' import { ArchonServersV0Module } from './archon/servers/v0' import { ArchonServersV1Module } from './archon/servers/v1' +import { ArchonTransfersInternalModule } from './archon/transfers/internal' import { ISO3166Module } from './iso3166' import { KyrosContentV1Module } from './kyros/content/v1' import { KyrosFilesV0Module } from './kyros/files/v0' @@ -60,10 +64,14 @@ export const MODULE_REGISTRY = { archon_backups_queue_v1: ArchonBackupsQueueV1Module, archon_backups_v1: ArchonBackupsV1Module, archon_content_v1: ArchonContentV1Module, + archon_nodes_internal: ArchonNodesInternalModule, + archon_notices_v0: ArchonNoticesV0Module, archon_options_v1: ArchonOptionsV1Module, archon_properties_v1: ArchonPropertiesV1Module, + archon_server_users_v1: ArchonServerUsersV1Module, archon_servers_v0: ArchonServersV0Module, archon_servers_v1: ArchonServersV1Module, + archon_transfers_internal: ArchonTransfersInternalModule, iso3166_data: ISO3166Module, mclogs_insights_v1: MclogsInsightsV1Module, mclogs_logs_v1: MclogsLogsV1Module, diff --git a/packages/api-client/src/platform/xhr-upload-client.ts b/packages/api-client/src/platform/xhr-upload-client.ts index d16e765899..40190f96d4 100644 --- a/packages/api-client/src/platform/xhr-upload-client.ts +++ b/packages/api-client/src/platform/xhr-upload-client.ts @@ -18,9 +18,9 @@ export abstract class XHRUploadClient extends AbstractModrinthClient { upload(path: string, options: UploadRequestOptions): UploadHandle { let baseUrl: string if (options.api === 'labrinth') { - baseUrl = this.config.labrinthBaseUrl! + baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!) } else if (options.api === 'archon') { - baseUrl = this.config.archonBaseUrl! + baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!) } else { baseUrl = options.api } diff --git a/packages/api-client/src/types/client.ts b/packages/api-client/src/types/client.ts index 45828d5c20..30ea76b259 100644 --- a/packages/api-client/src/types/client.ts +++ b/packages/api-client/src/types/client.ts @@ -1,6 +1,7 @@ import type { AbstractFeature } from '../core/abstract-feature' import type { RequestContext } from './request' +export type BaseUrlConfig = string | (() => string) export type MaybePromise = T | Promise export type UserAgentProvider = string | (() => MaybePromise) @@ -39,13 +40,15 @@ export interface ClientConfig { * Base URL for Labrinth API (main Modrinth API) * @default 'https://api.modrinth.com' */ - labrinthBaseUrl?: string + labrinthBaseUrl?: BaseUrlConfig /** * Base URL for Archon API (Modrinth Hosting API) + * Can be a callback so apps can drive this from runtime feature flags. + * * @default 'https://archon.modrinth.com' */ - archonBaseUrl?: string + archonBaseUrl?: BaseUrlConfig /** * Default request timeout in milliseconds diff --git a/packages/api-client/src/types/index.ts b/packages/api-client/src/types/index.ts index 30daf6520c..2fb40b14e4 100644 --- a/packages/api-client/src/types/index.ts +++ b/packages/api-client/src/types/index.ts @@ -7,7 +7,7 @@ export type { } from '../features/circuit-breaker' export type { BackoffStrategy, RetryConfig } from '../features/retry' export type { Archon } from '../modules/archon/types' -export type { ClientConfig, RequestHooks } from './client' +export type { BaseUrlConfig, ClientConfig, RequestHooks } from './client' export type { ApiErrorData, ModrinthErrorResponse } from './errors' export { isModrinthErrorResponse } from './errors' export type { HttpMethod, RequestContext, RequestOptions, ResponseData } from './request' diff --git a/packages/assets/external/illustrations/intercom_bubble_icon.png b/packages/assets/external/illustrations/intercom_bubble_icon.png new file mode 100644 index 0000000000..6585b9b09e Binary files /dev/null and b/packages/assets/external/illustrations/intercom_bubble_icon.png differ diff --git a/packages/assets/index.ts b/packages/assets/index.ts index 4fb4028886..7a8ed170cd 100644 --- a/packages/assets/index.ts +++ b/packages/assets/index.ts @@ -41,6 +41,7 @@ import _DiscordIcon from './external/discord.svg?component' import _FacebookIcon from './external/facebook.svg?component' import _FlathubIcon from './external/flathub.svg?component' import _GithubIcon from './external/github.svg?component' +import _IntercomBubbleIcon from './external/illustrations/intercom_bubble_icon.png?url' import _MinecraftServerIcon from './external/illustrations/minecraft_server_icon.png?url' import _InstagramIcon from './external/instagram.svg?component' import _KoFiIcon from './external/kofi.svg?component' @@ -132,6 +133,7 @@ export const VenmoIcon = _VenmoIcon export const PolygonIcon = _PolygonIcon export const USDCColorIcon = _USDCColorIcon export const VisaIcon = _VisaIcon +export const IntercomBubbleIcon = _IntercomBubbleIcon export const MinecraftServerIcon = _MinecraftServerIcon export * from './generated-icons' diff --git a/packages/ui/src/components/base/Combobox.vue b/packages/ui/src/components/base/Combobox.vue index bf340ffd58..c2b65ff568 100644 --- a/packages/ui/src/components/base/Combobox.vue +++ b/packages/ui/src/components/base/Combobox.vue @@ -95,6 +95,7 @@ class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] bg-surface-4 border border-solid border-surface-5" :class="[ openDirection === 'up' ? 'shadow-[0_-25px_50px_-12px_rgb(0,0,0,0.25)]' : 'shadow-2xl', + props.dropdownClass, ]" :style="dropdownStyle" :role="listbox ? 'listbox' : 'menu'" @@ -157,7 +158,7 @@ -
+
{{ noOptionsMessage }}
@@ -229,6 +230,9 @@ const props = withDefaults( forceDirection?: 'up' | 'down' noOptionsMessage?: string disableSearchFilter?: boolean + dropdownClass?: string + dropdownMinWidth?: string + minSearchLengthToOpen?: number /** Keep the selected option's label in the input after selection, and show all options on focus */ syncWithSelection?: boolean /** Show a search icon in the searchable input */ @@ -244,6 +248,7 @@ const props = withDefaults( showIconInSelected: false, maxHeight: DEFAULT_MAX_HEIGHT, noOptionsMessage: 'No results found', + minSearchLengthToOpen: 0, syncWithSelection: true, showSearchIcon: false, }, @@ -283,6 +288,7 @@ const dropdownStyle = ref({ top: '0px', left: '0px', width: '0px', + minWidth: '0px', }) const openDirection = ref<'down' | 'up'>('down') @@ -316,6 +322,10 @@ const triggerText = computed(() => { return props.placeholder }) +const hasMinimumSearchLength = computed( + () => !props.searchable || searchQuery.value.trim().length >= props.minSearchLengthToOpen, +) + const optionsWithKeys = computed(() => { return props.options.map((opt, index) => ({ ...opt, @@ -426,6 +436,7 @@ async function updateDropdownPosition() { top: `${top}px`, left: `${left}px`, width: `${triggerRect.width}px`, + minWidth: props.dropdownMinWidth ?? `${triggerRect.width}px`, } openDirection.value = direction @@ -433,6 +444,7 @@ async function updateDropdownPosition() { async function openDropdown() { if (props.disabled || isOpen.value) return + if (!hasMinimumSearchLength.value) return isOpen.value = true emit('open') @@ -622,6 +634,10 @@ function handleSearchKeydown(event: KeyboardEvent) { function handleSearchInput() { userHasTyped.value = true emit('searchInput', searchQuery.value) + if (!hasMinimumSearchLength.value) { + closeDropdown() + return + } if (!isOpen.value) { openDropdown() } @@ -689,10 +705,16 @@ watch(filteredOptions, () => { } }) +watch(hasMinimumSearchLength, (canOpen) => { + if (!canOpen) { + closeDropdown() + } +}) + watch( [() => props.modelValue, () => props.options], ([val]) => { - if (props.searchable && props.syncWithSelection && !isOpen.value) { + if (props.searchable && props.syncWithSelection && !isOpen.value && !userHasTyped.value) { const opt = props.options.find((o) => isDropdownOption(o) && o.value === val) searchQuery.value = opt && isDropdownOption(opt) ? opt.label : '' } diff --git a/packages/ui/src/components/base/Table.vue b/packages/ui/src/components/base/Table.vue index d952a5337b..c39354d637 100644 --- a/packages/ui/src/components/base/Table.vue +++ b/packages/ui/src/components/base/Table.vue @@ -14,31 +14,30 @@ - {{ column.label ?? '' }} - + + + + + {{ column.label }} diff --git a/packages/ui/src/components/servers/ServerListing.vue b/packages/ui/src/components/servers/ServerListing.vue index 744c9f74dd..26e6f69e71 100644 --- a/packages/ui/src/components/servers/ServerListing.vue +++ b/packages/ui/src/components/servers/ServerListing.vue @@ -39,6 +39,22 @@

{{ name }}

+
+ + {{ owner.username }} +
void) | null onDownloadBackup?: (() => void) | null + owner?: ServerListingOwner } const props = defineProps() diff --git a/packages/ui/src/components/servers/access/AccessTable.vue b/packages/ui/src/components/servers/access/AccessTable.vue new file mode 100644 index 0000000000..418ba579b3 --- /dev/null +++ b/packages/ui/src/components/servers/access/AccessTable.vue @@ -0,0 +1,573 @@ + + + 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..282ae33e1d --- /dev/null +++ b/packages/ui/src/components/servers/access/AuditLogTable.vue @@ -0,0 +1,802 @@ + + + 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..489fcab349 --- /dev/null +++ b/packages/ui/src/components/servers/access/GrantAccessModal.vue @@ -0,0 +1,347 @@ + + + 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..5633d02480 --- /dev/null +++ b/packages/ui/src/components/servers/access/RemoveAccessModal.vue @@ -0,0 +1,268 @@ + + + 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..8f871d4e48 --- /dev/null +++ b/packages/ui/src/components/servers/access/index.ts @@ -0,0 +1,5 @@ +export { default as AccessTable } from './AccessTable.vue' +export { default as AuditLogTable } from './AuditLogTable.vue' +export { default as GrantAccessModal } from './GrantAccessModal.vue' +export { default as RemoveAccessModal } from './RemoveAccessModal.vue' +export * from './types' 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..d4acd06bd1 --- /dev/null +++ b/packages/ui/src/components/servers/access/types.ts @@ -0,0 +1,71 @@ +export type ServerAccessRole = 'owner' | 'editor' | 'viewer' + +export interface ServerAccessUser { + 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 type ServerAuditAction = + | { type: 'file_edited'; file: string } + | { type: 'world_started'; worldName: string } + | { + type: 'content_installed' + contentType: 'mod' | 'modpack' + name: string + iconUrl?: string + href?: string + version?: string + } + | { type: 'member_invited'; target: string; role?: Exclude } + | { type: 'member_removed'; target: string } + | { type: 'role_changed'; target: string; role?: ServerAccessRole } + +export type ServerAuditActionType = ServerAuditAction['type'] + +export interface ServerAuditLogEntry { + id: string + actor: ServerAccessUser | { id: 'support'; username: 'Support' } + world: { id: string; name: string } | null + action: ServerAuditAction + timestamp: string +} + +export interface ServerAuditLogFilters { + userId: string | null + worldId: string | null + actionType: ServerAuditActionType | 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 +} + +export interface ServerListingOwner { + username: string + avatarUrl?: string +} 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..cb9b1c4c1c 100644 --- a/packages/ui/src/components/servers/marketing/MedalServerListing.vue +++ b/packages/ui/src/components/servers/marketing/MedalServerListing.vue @@ -43,6 +43,22 @@ > {{ 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/composables/server-manage-core-runtime.ts b/packages/ui/src/composables/server-manage-core-runtime.ts index 4d6665ac40..d803f183d7 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 } from '../providers/server-context' +import type { BusyReason, ServerStats } from '../providers/server-context' import { defineMessage } from './i18n' import { useModrinthServersConsole } from './server-console' @@ -35,7 +34,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 +90,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 +140,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) diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/access.vue b/packages/ui/src/layouts/wrapped/hosting/manage/access.vue new file mode 100644 index 0000000000..99ede40b0c --- /dev/null +++ b/packages/ui/src/layouts/wrapped/hosting/manage/access.vue @@ -0,0 +1,671 @@ + + + diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/components/ServerManageStats.vue b/packages/ui/src/layouts/wrapped/hosting/manage/components/ServerManageStats.vue index 04e4949440..ac3dfe1ec8 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/components/ServerManageStats.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/components/ServerManageStats.vue @@ -60,12 +60,12 @@