mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 17:44:50 +00:00
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:
@@ -28,7 +28,13 @@
|
||||
</div>
|
||||
<template v-if="contentError" #top-right-actions>
|
||||
<ButtonStyled color="red" type="outlined">
|
||||
<button class="!border" type="button" @click="emit('retry')">
|
||||
<button
|
||||
v-tooltip="retryDisabled ? retryDisabledTooltip : undefined"
|
||||
class="!border"
|
||||
type="button"
|
||||
:disabled="retryDisabled"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
<RotateCounterClockwiseIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.retryButton) }}
|
||||
</button>
|
||||
@@ -62,6 +68,8 @@ const props = defineProps<{
|
||||
fallbackPhase?: SyncProgress['phase'] | null
|
||||
contentError?: ContentError | null
|
||||
dismissible?: boolean
|
||||
retryDisabled?: boolean
|
||||
retryDisabledTooltip?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -35,10 +35,26 @@
|
||||
</div>
|
||||
<ServerIcon v-else :image="image ?? undefined" :disabled="isDisabled" />
|
||||
<div class="ml-4 flex flex-col gap-1.5">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="flex flex-row items-center gap-2.5">
|
||||
<h2 class="m-0 text-xl font-bold text-contrast" :class="{ 'opacity-50': isDisabled }">
|
||||
{{ name }}
|
||||
</h2>
|
||||
<div
|
||||
v-if="owner"
|
||||
v-tooltip="formatMessage(messages.ownerTooltip, { username: owner.username })"
|
||||
class="flex min-w-0 items-center gap-1 rounded-full bg-surface-4 px-2 pr-2.5 py-1 text-sm font-medium text-primary !border !border-surface-5 border-solid"
|
||||
:class="{ 'opacity-50': isDisabled }"
|
||||
>
|
||||
<Avatar
|
||||
:src="owner.avatarUrl"
|
||||
:alt="formatMessage(messages.ownerAvatarAlt, { username: owner.username })"
|
||||
:tint-by="owner.username"
|
||||
size="1.25rem"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<span class="max-w-32 truncate">{{ owner.username }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isConfiguring && noticeType !== 'cancelled' && noticeType !== 'setToCancel'"
|
||||
class="flex min-w-0 items-center gap-2 truncate text-sm font-medium text-brand rounded-full bg-brand-highlight border border-solid border-brand px-2.5 h-[28px]"
|
||||
@@ -262,6 +278,7 @@ import { injectModrinthClient } from '../../providers/api-client'
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
import IntlFormatted from '../base/IntlFormatted.vue'
|
||||
import ServersSpecs from '../billing/ServersSpecs.vue'
|
||||
import type { ServerListingOwner } from './access/types'
|
||||
import ServerIcon from './icons/ServerIcon.vue'
|
||||
import ServerInfoLabels from './labels/ServerInfoLabels.vue'
|
||||
|
||||
@@ -281,6 +298,14 @@ const messages = defineMessages({
|
||||
id: 'servers.listing.using-project-label',
|
||||
defaultMessage: 'Using {projectTitle}',
|
||||
},
|
||||
ownerTooltip: {
|
||||
id: 'servers.listing.owner-tooltip',
|
||||
defaultMessage: 'Owned by {username}',
|
||||
},
|
||||
ownerAvatarAlt: {
|
||||
id: 'servers.listing.owner-avatar-alt',
|
||||
defaultMessage: "{username}'s avatar",
|
||||
},
|
||||
provisioningNotice: {
|
||||
id: 'servers.listing.notice.provisioning',
|
||||
defaultMessage: 'Please wait while we set up your server. This can take up to 10 minutes.',
|
||||
@@ -402,6 +427,7 @@ type ServerListingProps = {
|
||||
cancellationDate?: string | Date | null
|
||||
onResubscribe?: (() => void) | null
|
||||
onDownloadBackup?: (() => void) | null
|
||||
owner?: ServerListingOwner
|
||||
}
|
||||
|
||||
const props = defineProps<ServerListingProps>()
|
||||
|
||||
@@ -60,13 +60,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CpuIcon, DatabaseIcon, FolderOpenIcon } from '@modrinth/assets'
|
||||
import type { Stats } from '@modrinth/utils'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed, defineAsyncComponent, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { useFormatBytes } from '#ui/composables'
|
||||
import { injectModrinthServerContext, injectPageContext } from '#ui/providers'
|
||||
import type { ServerStats } from '#ui/providers/server-context'
|
||||
|
||||
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
|
||||
|
||||
@@ -82,7 +82,7 @@ const { featureFlags } = injectPageContext()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
data?: Stats
|
||||
data?: ServerStats
|
||||
loading?: boolean
|
||||
showMemoryAsBytes?: boolean
|
||||
}>(),
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
:fade="props.initialSetup ? undefined : 'danger'"
|
||||
:search-modpacks="searchModpacks"
|
||||
:get-project-versions="getProjectVersions"
|
||||
:finish-disabled="!canCompleteSetup"
|
||||
:finish-disabled-tooltip="!canCompleteSetup ? permissionDeniedMessage : undefined"
|
||||
@create="onFlowComplete"
|
||||
@hide="$emit('hide')"
|
||||
@browse-modpacks="$emit('browse-modpacks')"
|
||||
@@ -24,6 +26,7 @@ import type { Archon, ModrinthApiError } from '@modrinth/api-client'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
import { useServerPermissions } from '#ui/composables/server-permissions'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import { injectModrinthClient } from '../../providers/api-client'
|
||||
@@ -60,6 +63,7 @@ const serverContext = injectModrinthServerContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const serverLoaders = ['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']
|
||||
const { canSetup, canResetServer, permissionDeniedMessage } = useServerPermissions()
|
||||
|
||||
async function searchModpacks(query: string, limit: number = 10) {
|
||||
return client.labrinth.projects_v2.search({
|
||||
@@ -97,12 +101,20 @@ const initialLoader = computed(() => {
|
||||
})
|
||||
|
||||
const initialGameVersion = computed(() => serverContext.server.value.mc_version ?? undefined)
|
||||
const canCompleteSetup = computed(() =>
|
||||
props.initialSetup ? canSetup.value : canResetServer.value,
|
||||
)
|
||||
|
||||
const creationFlowRef = useTemplateRef<InstanceType<typeof CreationFlowModal>>('creationFlowRef')
|
||||
const uploadProgressModal =
|
||||
useTemplateRef<InstanceType<typeof UploadProgressModal>>('uploadProgressModal')
|
||||
|
||||
async function onFlowComplete(ctx: CreationFlowContextValue) {
|
||||
if (!canCompleteSetup.value) {
|
||||
ctx.loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
debug('onFlowComplete:', {
|
||||
setupType: ctx.setupType.value,
|
||||
hasModpackFile: !!ctx.modpackFile.value,
|
||||
@@ -207,6 +219,8 @@ function emitReinstall(args?: { loader: string; lVersion: string; mVersion: stri
|
||||
}
|
||||
|
||||
function show() {
|
||||
if (!canCompleteSetup.value) return
|
||||
|
||||
void creationFlowRef.value?.ctx?.fetchLoaderMetadata('paper')
|
||||
void creationFlowRef.value?.ctx?.fetchLoaderMetadata('purpur')
|
||||
creationFlowRef.value?.show()
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
<template>
|
||||
<Table
|
||||
v-if="members.length > 0"
|
||||
v-model:sort-column="sortColumn"
|
||||
v-model:sort-direction="sortDirection"
|
||||
class="hidden sm:block"
|
||||
:columns="columns"
|
||||
:data="tableMembers"
|
||||
row-key="id"
|
||||
table-min-width="42rem"
|
||||
>
|
||||
<template #cell-user="{ row: member }">
|
||||
<AutoLink
|
||||
:to="userProfilePath(member.user.username)"
|
||||
class="inline-flex max-w-full min-w-0 items-center gap-2"
|
||||
:class="userProfilePath(member.user.username) ? 'text-primary hover:underline' : ''"
|
||||
>
|
||||
<Avatar
|
||||
:src="member.user.avatarUrl"
|
||||
:alt="formatMessage(messages.userAvatarAlt, { username: member.user.username })"
|
||||
:tint-by="member.user.username"
|
||||
size="22px"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<span class="min-w-0 truncate font-medium">
|
||||
{{ member.user.username }}
|
||||
</span>
|
||||
</AutoLink>
|
||||
</template>
|
||||
|
||||
<template #cell-role="{ row: member }">
|
||||
<span
|
||||
v-if="member.isOwner"
|
||||
class="inline-flex h-7 items-center rounded-full border border-solid px-2.5 py-1 text-sm font-semibold leading-none"
|
||||
:class="roleClasses(member.role)"
|
||||
>
|
||||
{{ formatRole(member.role) }}
|
||||
</span>
|
||||
<div v-else v-tooltip="accessManagementTooltip" class="w-fit">
|
||||
<Combobox
|
||||
:model-value="member.role"
|
||||
:options="roleComboboxOptions"
|
||||
:display-value="formatRole(member.role)"
|
||||
:disabled="!canManageUsers"
|
||||
:trigger-class="`${roleTriggerClass(member.role)} !inline-flex !w-auto !h-7 !min-h-0 !rounded-full !border !border-solid !px-2.5 !py-1 gap-1 !text-sm !font-semibold !leading-5`"
|
||||
dropdown-class="!rounded-[24px] !bg-surface-3"
|
||||
dropdown-min-width="18rem"
|
||||
force-direction="down"
|
||||
@update:model-value="(role) => handleUpdateRole(member, role)"
|
||||
>
|
||||
<template #selected>
|
||||
<span class="font-semibold leading-5" :class="roleTextClass(member.role)">
|
||||
{{ formatRole(member.role) }}
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-joined="{ row: member }">
|
||||
<span
|
||||
v-if="member.pending"
|
||||
class="inline-flex h-7 items-center rounded-full border border-surface-5 border-solid bg-surface-4 px-2.5 py-1 text-sm font-semibold text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.pendingLabel) }}
|
||||
</span>
|
||||
<span v-else-if="member.joinedAt" v-tooltip="formatDate(member.joinedAt)">
|
||||
{{ formatRelativeTime(member.joinedAt) }}
|
||||
</span>
|
||||
<span v-else>{{ formatMessage(messages.unknownJoinedDate) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row: member }">
|
||||
<div v-if="!member.isOwner" class="flex items-center justify-end gap-1">
|
||||
<ButtonStyled v-if="member.pending" circular type="transparent">
|
||||
<button
|
||||
v-tooltip="resendInviteTooltip(member)"
|
||||
:aria-label="resendInviteLabel(member)"
|
||||
:disabled="resendInviteDisabled(member)"
|
||||
class="text-secondary hover:!filter-none hover:text-contrast focus-visible:!filter-none active:!scale-100 active:!filter-none disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:text-secondary"
|
||||
@click="handleResendInvite(member)"
|
||||
>
|
||||
<SendIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="memberAccessActionTooltip(member)"
|
||||
:aria-label="memberAccessActionLabel(member)"
|
||||
:disabled="!canManageUsers"
|
||||
class="text-secondary hover:!filter-none hover:text-red focus-visible:!filter-none active:!scale-100 active:!filter-none disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:text-secondary"
|
||||
@click="member.pending ? handleCancelInvite(member) : handleRemoveMember(member)"
|
||||
>
|
||||
<XIcon v-if="member.pending" aria-hidden="true" />
|
||||
<UserXIcon v-else aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<div
|
||||
v-if="members.length > 0"
|
||||
class="overflow-hidden rounded-2xl border border-solid border-surface-5 sm:hidden"
|
||||
>
|
||||
<div
|
||||
class="grid min-h-14 grid-cols-[minmax(0,1.35fr)_7.75rem_minmax(6rem,0.8fr)_4rem] bg-surface-3"
|
||||
>
|
||||
<div class="flex items-center pl-4 font-semibold text-secondary">
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-semibold transition-colors hover:text-contrast"
|
||||
:class="sortColumn === 'user' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="toggleSort('user')"
|
||||
>
|
||||
<span class="min-w-0 truncate">{{ formatMessage(messages.userColumn) }}</span>
|
||||
<component :is="sortIcon('user')" v-if="sortIcon('user')" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center font-semibold text-secondary">
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-semibold transition-colors hover:text-contrast"
|
||||
:class="sortColumn === 'role' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="toggleSort('role')"
|
||||
>
|
||||
{{ formatMessage(messages.roleColumn) }}
|
||||
<component :is="sortIcon('role')" v-if="sortIcon('role')" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-end font-semibold text-secondary">
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-semibold transition-colors hover:text-contrast"
|
||||
:class="sortColumn === 'joined' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="toggleSort('joined')"
|
||||
>
|
||||
{{ formatMessage(messages.joinedColumn) }}
|
||||
<component :is="sortIcon('joined')" v-if="sortIcon('joined')" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-end pr-4 font-semibold text-secondary">
|
||||
<span class="sr-only">{{ formatMessage(messages.actionsColumn) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(member, index) in sortedMembers"
|
||||
:key="member.id"
|
||||
class="grid min-h-16 grid-cols-[minmax(0,1.35fr)_7.75rem_minmax(6rem,0.8fr)_4rem] items-center border-0 border-t border-solid border-surface-5"
|
||||
:class="index % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
|
||||
>
|
||||
<div class="flex min-w-0 items-center pl-4">
|
||||
<AutoLink
|
||||
v-tooltip="member.user.username"
|
||||
:to="userProfilePath(member.user.username)"
|
||||
class="inline-flex min-w-0 items-center gap-2"
|
||||
:class="userProfilePath(member.user.username) ? 'text-primary hover:underline' : ''"
|
||||
>
|
||||
<Avatar
|
||||
:src="member.user.avatarUrl"
|
||||
:alt="formatMessage(messages.userAvatarAlt, { username: member.user.username })"
|
||||
:tint-by="member.user.username"
|
||||
size="24px"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<span class="min-w-0 truncate font-medium">
|
||||
{{ member.user.username }}
|
||||
</span>
|
||||
</AutoLink>
|
||||
</div>
|
||||
<div class="min-w-0 py-3 pr-2">
|
||||
<span
|
||||
v-if="member.isOwner"
|
||||
class="inline-flex h-7 max-w-full items-center truncate rounded-full border border-solid px-2.5 py-1 text-sm font-semibold leading-none"
|
||||
:class="roleClasses(member.role)"
|
||||
>
|
||||
{{ formatRole(member.role) }}
|
||||
</span>
|
||||
<div v-else v-tooltip="accessManagementTooltip" class="min-w-0">
|
||||
<Combobox
|
||||
:model-value="member.role"
|
||||
:options="roleComboboxOptions"
|
||||
:display-value="formatRole(member.role)"
|
||||
:disabled="!canManageUsers"
|
||||
:trigger-class="
|
||||
roleTriggerClass(member.role) +
|
||||
` !inline-flex !w-auto !max-w-full !h-7 !min-h-0 !rounded-full !border !border-solid !px-2.5 !py-1 gap-1 !text-sm !font-semibold !leading-5`
|
||||
"
|
||||
dropdown-class="!rounded-[24px] !bg-surface-3"
|
||||
dropdown-min-width="18rem"
|
||||
force-direction="down"
|
||||
@update:model-value="(role) => handleUpdateRole(member, role)"
|
||||
>
|
||||
<template #selected>
|
||||
<span
|
||||
class="min-w-0 truncate font-semibold leading-5"
|
||||
:class="roleTextClass(member.role)"
|
||||
>
|
||||
{{ formatRole(member.role) }}
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 py-3 pr-2 text-right text-secondary">
|
||||
<span
|
||||
v-if="member.pending"
|
||||
class="inline-flex h-7 max-w-full items-center rounded-full border border-surface-5 border-solid bg-surface-4 px-2.5 py-1 text-sm font-semibold text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.pendingLabel) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="member.joinedAt"
|
||||
v-tooltip="formatDate(member.joinedAt)"
|
||||
class="inline-block max-w-full truncate"
|
||||
>
|
||||
{{ formatRelativeTime(member.joinedAt) }}
|
||||
</span>
|
||||
<span v-else>{{ formatMessage(messages.unknownJoinedDate) }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center justify-end pr-4">
|
||||
<ButtonStyled v-if="!member.isOwner" circular type="transparent">
|
||||
<TeleportOverflowMenu
|
||||
:options="memberActionOptions(member)"
|
||||
btn-class="hover:!filter-none focus-visible:!filter-none active:!scale-100 active:!filter-none"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" class="size-5" />
|
||||
<span class="sr-only">
|
||||
{{ formatMessage(messages.memberActionsLabel, { username: member.user.username }) }}
|
||||
</span>
|
||||
<template #resend-invite>
|
||||
<SendIcon aria-hidden="true" />
|
||||
{{ resendInviteLabel(member) }}
|
||||
</template>
|
||||
<template #cancel-invite>
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.cancelInvite) }}
|
||||
</template>
|
||||
<template #remove-user>
|
||||
<UserXIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.removeUser) }}
|
||||
</template>
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-hidden rounded-2xl border border-solid border-surface-5">
|
||||
<div
|
||||
class="grid min-h-14 grid-cols-[3.75rem_7.25rem_minmax(0,1fr)_2.75rem] bg-surface-3 sm:h-14 sm:grid-cols-[32%_28%_28%_12%]"
|
||||
>
|
||||
<div class="flex items-center pl-4 font-semibold text-secondary">
|
||||
{{ formatMessage(messages.userColumn) }}
|
||||
</div>
|
||||
<div class="flex items-center font-semibold text-secondary">
|
||||
{{ formatMessage(messages.roleColumn) }}
|
||||
</div>
|
||||
<div class="flex items-center font-semibold text-secondary">
|
||||
{{ formatMessage(messages.joinedColumn) }}
|
||||
</div>
|
||||
<div class="flex items-center justify-end pr-4 font-semibold text-secondary">
|
||||
{{ formatMessage(messages.actionsColumn) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-0 border-t border-solid border-surface-5 bg-surface-2 px-4 py-8 text-center text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.emptyState) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
MoreVerticalIcon,
|
||||
SendIcon,
|
||||
UserXIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { type Component, computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useFormatDateTime, useRelativeTime } from '../../../composables'
|
||||
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
||||
import { commonMessages } from '../../../utils/common-messages'
|
||||
import AutoLink from '../../base/AutoLink.vue'
|
||||
import Avatar from '../../base/Avatar.vue'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import Combobox, { type ComboboxOption } from '../../base/Combobox.vue'
|
||||
import Table, { type SortDirection, type TableColumn } from '../../base/Table.vue'
|
||||
import TeleportOverflowMenu from '../../base/TeleportOverflowMenu.vue'
|
||||
import type { ServerAccessMember, ServerAccessRole, ServerAccessRoleOption } from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
members: ServerAccessMember[]
|
||||
roles: ServerAccessRoleOption[]
|
||||
canManageUsers?: boolean
|
||||
permissionDeniedMessage?: string
|
||||
}>(),
|
||||
{
|
||||
canManageUsers: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateRole: [member: ServerAccessMember, role: ServerAccessRole]
|
||||
resendInvite: [member: ServerAccessMember]
|
||||
cancelInvite: [member: ServerAccessMember]
|
||||
removeMember: [member: ServerAccessMember]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const formatDate = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
|
||||
|
||||
const messages = defineMessages({
|
||||
userColumn: {
|
||||
id: 'servers.access-table.column.user',
|
||||
defaultMessage: 'User',
|
||||
},
|
||||
roleColumn: {
|
||||
id: 'servers.access-table.column.role',
|
||||
defaultMessage: 'Role',
|
||||
},
|
||||
joinedColumn: {
|
||||
id: 'servers.access-table.column.joined',
|
||||
defaultMessage: 'Joined',
|
||||
},
|
||||
actionsColumn: {
|
||||
id: 'servers.access-table.column.actions',
|
||||
defaultMessage: 'Actions',
|
||||
},
|
||||
memberActionsLabel: {
|
||||
id: 'servers.access-table.member-actions-label',
|
||||
defaultMessage: 'Actions for {username}',
|
||||
},
|
||||
pendingLabel: {
|
||||
id: 'servers.access-table.pending',
|
||||
defaultMessage: 'Pending',
|
||||
},
|
||||
unknownJoinedDate: {
|
||||
id: 'servers.access-table.unknown-joined-date',
|
||||
defaultMessage: '—',
|
||||
},
|
||||
resendInvite: {
|
||||
id: 'servers.access-table.action.resend-invite',
|
||||
defaultMessage: 'Resend invite',
|
||||
},
|
||||
cancelInvite: {
|
||||
id: 'servers.access-table.action.cancel-invite',
|
||||
defaultMessage: 'Cancel invite',
|
||||
},
|
||||
removeUser: {
|
||||
id: 'servers.access-table.action.remove-user',
|
||||
defaultMessage: 'Remove user',
|
||||
},
|
||||
emptyState: {
|
||||
id: 'servers.access-table.empty',
|
||||
defaultMessage: 'No users match your filters.',
|
||||
},
|
||||
userAvatarAlt: {
|
||||
id: 'servers.access-table.user-avatar-alt',
|
||||
defaultMessage: "{username}'s avatar",
|
||||
},
|
||||
ownerRole: {
|
||||
id: 'servers.access-role.owner',
|
||||
defaultMessage: 'Owner',
|
||||
},
|
||||
editorRole: {
|
||||
id: 'servers.access-role.editor',
|
||||
defaultMessage: 'Editor',
|
||||
},
|
||||
viewerRole: {
|
||||
id: 'servers.access-role.viewer',
|
||||
defaultMessage: 'Limited',
|
||||
},
|
||||
resendInviteCooldown: {
|
||||
id: 'servers.access-table.action.resend-invite-cooldown',
|
||||
defaultMessage: 'Resend in {seconds}s',
|
||||
},
|
||||
})
|
||||
|
||||
type AccessTableColumn = 'user' | 'role' | 'joined' | 'actions'
|
||||
type AccessTableSortableColumn = Exclude<AccessTableColumn, 'actions'>
|
||||
type AccessTableRow = ServerAccessMember & Record<string, unknown>
|
||||
type OverflowMenuOption = {
|
||||
id: string
|
||||
icon?: Component
|
||||
action: () => void
|
||||
shown?: boolean
|
||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
const columns = computed<TableColumn<AccessTableColumn>[]>(() => [
|
||||
{ key: 'user', label: formatMessage(messages.userColumn), width: '32%', enableSorting: true },
|
||||
{ key: 'role', label: formatMessage(messages.roleColumn), width: '28%', enableSorting: true },
|
||||
{ key: 'joined', label: formatMessage(messages.joinedColumn), enableSorting: true },
|
||||
{ key: 'actions', label: formatMessage(messages.actionsColumn), align: 'right', width: '7rem' },
|
||||
])
|
||||
|
||||
const sortColumn = ref<string | undefined>('role')
|
||||
const sortDirection = ref<SortDirection>('asc')
|
||||
const now = ref(Date.now())
|
||||
let nowInterval: ReturnType<typeof setInterval> | null = null
|
||||
const canManageUsers = computed(() => props.canManageUsers)
|
||||
const permissionDeniedMessage = computed(
|
||||
() => props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction),
|
||||
)
|
||||
const accessManagementTooltip = computed(() =>
|
||||
canManageUsers.value ? undefined : permissionDeniedMessage.value,
|
||||
)
|
||||
|
||||
const roleSortOrder: Record<ServerAccessRole, number> = {
|
||||
owner: 0,
|
||||
editor: 1,
|
||||
viewer: 2,
|
||||
}
|
||||
|
||||
const sortedMembers = computed(() => {
|
||||
const direction = sortDirection.value === 'asc' ? 1 : -1
|
||||
const column = normalizeSortColumn(sortColumn.value)
|
||||
|
||||
return [...props.members].sort((a, b) => {
|
||||
const compared = compareMembers(a, b, column)
|
||||
if (compared !== 0) return compared * direction
|
||||
|
||||
return a.user.username.localeCompare(b.user.username)
|
||||
})
|
||||
})
|
||||
|
||||
const tableMembers = computed<AccessTableRow[]>(() => sortedMembers.value as AccessTableRow[])
|
||||
|
||||
onMounted(() => {
|
||||
nowInterval = setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (nowInterval) clearInterval(nowInterval)
|
||||
})
|
||||
|
||||
function formatRole(role: ServerAccessRole): string {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return formatMessage(messages.ownerRole)
|
||||
case 'editor':
|
||||
return formatMessage(messages.editorRole)
|
||||
case 'viewer':
|
||||
return formatMessage(messages.viewerRole)
|
||||
}
|
||||
}
|
||||
|
||||
const roleComboboxOptions = computed<ComboboxOption<ServerAccessRole>[]>(() =>
|
||||
props.roles
|
||||
.filter((role) => role.value !== 'owner')
|
||||
.map((role) => ({
|
||||
value: role.value,
|
||||
label: role.label,
|
||||
subLabel: role.description,
|
||||
})),
|
||||
)
|
||||
|
||||
function compareMembers(
|
||||
a: ServerAccessMember,
|
||||
b: ServerAccessMember,
|
||||
column: AccessTableSortableColumn,
|
||||
): number {
|
||||
switch (column) {
|
||||
case 'user':
|
||||
return a.user.username.localeCompare(b.user.username)
|
||||
case 'role':
|
||||
return roleSortOrder[a.role] - roleSortOrder[b.role]
|
||||
case 'joined':
|
||||
return joinedTimestamp(a) - joinedTimestamp(b)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSortColumn(column: string | undefined): AccessTableSortableColumn {
|
||||
return column === 'user' || column === 'role' || column === 'joined' ? column : 'joined'
|
||||
}
|
||||
|
||||
function joinedTimestamp(member: ServerAccessMember): number {
|
||||
if (member.pending) return Number.NEGATIVE_INFINITY
|
||||
return member.joinedAt ? new Date(member.joinedAt).getTime() : 0
|
||||
}
|
||||
|
||||
function toggleSort(column: AccessTableSortableColumn) {
|
||||
if (sortColumn.value === column) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
||||
return
|
||||
}
|
||||
|
||||
sortColumn.value = column
|
||||
sortDirection.value = 'asc'
|
||||
}
|
||||
|
||||
function sortIcon(column: AccessTableSortableColumn): Component | null {
|
||||
if (sortColumn.value !== column) return null
|
||||
return sortDirection.value === 'asc' ? ChevronUpIcon : ChevronDownIcon
|
||||
}
|
||||
|
||||
function roleClasses(role: ServerAccessRole): string {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return 'border-orange !bg-highlight-orange !text-orange'
|
||||
case 'editor':
|
||||
return 'border-green !bg-highlight-green !text-brand'
|
||||
case 'viewer':
|
||||
return 'border-blue !bg-highlight-blue !text-blue'
|
||||
}
|
||||
}
|
||||
|
||||
function roleTextClass(role: ServerAccessRole): string {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return '!text-orange'
|
||||
case 'editor':
|
||||
return '!text-green'
|
||||
case 'viewer':
|
||||
return '!text-blue'
|
||||
}
|
||||
}
|
||||
|
||||
function roleTriggerClass(role: ServerAccessRole): string {
|
||||
return roleClasses(role)
|
||||
}
|
||||
|
||||
function userProfilePath(username: string): string | undefined {
|
||||
if (!username || username.includes('@')) return undefined
|
||||
return `/user/${encodeURIComponent(username)}`
|
||||
}
|
||||
|
||||
function resendInviteCooldownSeconds(member: ServerAccessMember): number {
|
||||
const availableAt = member.inviteResendAvailableAt
|
||||
? new Date(member.inviteResendAvailableAt).getTime()
|
||||
: 0
|
||||
return Math.max(0, Math.ceil((availableAt - now.value) / 1000))
|
||||
}
|
||||
|
||||
function resendInviteCooldownDisabled(member: ServerAccessMember): boolean {
|
||||
return resendInviteCooldownSeconds(member) > 0
|
||||
}
|
||||
|
||||
function resendInviteDisabled(member: ServerAccessMember): boolean {
|
||||
return !canManageUsers.value || resendInviteCooldownDisabled(member)
|
||||
}
|
||||
|
||||
function resendInviteLabel(member: ServerAccessMember): string {
|
||||
const seconds = resendInviteCooldownSeconds(member)
|
||||
return seconds > 0
|
||||
? formatMessage(messages.resendInviteCooldown, { seconds })
|
||||
: formatMessage(messages.resendInvite)
|
||||
}
|
||||
|
||||
function resendInviteTooltip(member: ServerAccessMember): string {
|
||||
return canManageUsers.value ? resendInviteLabel(member) : permissionDeniedMessage.value
|
||||
}
|
||||
|
||||
function handleResendInvite(member: ServerAccessMember) {
|
||||
if (resendInviteDisabled(member)) return
|
||||
emit('resendInvite', member)
|
||||
}
|
||||
|
||||
function memberAccessActionLabel(member: ServerAccessMember): string {
|
||||
return member.pending ? formatMessage(messages.cancelInvite) : formatMessage(messages.removeUser)
|
||||
}
|
||||
|
||||
function memberAccessActionTooltip(member: ServerAccessMember): string {
|
||||
return canManageUsers.value ? memberAccessActionLabel(member) : permissionDeniedMessage.value
|
||||
}
|
||||
|
||||
function handleUpdateRole(member: ServerAccessMember, role: ServerAccessRole) {
|
||||
if (!canManageUsers.value) return
|
||||
emit('updateRole', member, role)
|
||||
}
|
||||
|
||||
function handleCancelInvite(member: ServerAccessMember) {
|
||||
if (!canManageUsers.value) return
|
||||
emit('cancelInvite', member)
|
||||
}
|
||||
|
||||
function handleRemoveMember(member: ServerAccessMember) {
|
||||
if (!canManageUsers.value) return
|
||||
emit('removeMember', member)
|
||||
}
|
||||
|
||||
function memberActionOptions(member: ServerAccessMember): OverflowMenuOption[] {
|
||||
return [
|
||||
{
|
||||
id: 'resend-invite',
|
||||
icon: SendIcon,
|
||||
action: () => handleResendInvite(member),
|
||||
shown: member.pending,
|
||||
disabled: resendInviteDisabled(member),
|
||||
tooltip: resendInviteTooltip(member),
|
||||
},
|
||||
{
|
||||
id: 'cancel-invite',
|
||||
icon: XIcon,
|
||||
action: () => handleCancelInvite(member),
|
||||
color: 'red',
|
||||
shown: member.pending,
|
||||
disabled: !canManageUsers.value,
|
||||
tooltip: memberAccessActionTooltip(member),
|
||||
},
|
||||
{
|
||||
id: 'remove-user',
|
||||
icon: UserXIcon,
|
||||
action: () => handleRemoveMember(member),
|
||||
color: 'red',
|
||||
shown: !member.pending,
|
||||
disabled: !canManageUsers.value,
|
||||
tooltip: memberAccessActionTooltip(member),
|
||||
},
|
||||
]
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="audit-log-table-event min-w-0 truncate">
|
||||
<component :is="event.component" v-bind="event.props" class="audit-log-table-event-component" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ParsedAuditEvent } from './events/types'
|
||||
|
||||
defineProps<{
|
||||
event: ParsedAuditEvent
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,706 @@
|
||||
<template>
|
||||
<div class="@container flex flex-col gap-4">
|
||||
<div class="flex min-w-0 flex-col items-start gap-3 @[640px]:flex-row @[640px]:items-center">
|
||||
<TimeFramePicker
|
||||
v-model:mode="timeframeMode"
|
||||
v-model:preset="timeframePreset"
|
||||
v-model:last-amount="timeframeLastAmount"
|
||||
v-model:last-unit="timeframeLastUnit"
|
||||
v-model:custom-start-date="timeframeCustomStartDate"
|
||||
v-model:custom-end-date="timeframeCustomEndDate"
|
||||
:class="timeframePickerClass"
|
||||
:trigger-class="timeframePickerTriggerClass"
|
||||
/>
|
||||
<template v-if="slots.filters">
|
||||
<div class="hidden h-8 w-[1px] shrink-0 bg-surface-5 @[640px]:ml-1 @[640px]:block"></div>
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<slot name="filters"></slot>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="audit-log-content-frame relative overflow-hidden" :style="contentFrameStyle">
|
||||
<div ref="contentBody" class="min-w-0">
|
||||
<Table
|
||||
v-if="filteredEntries.length > 0"
|
||||
v-model:sort-column="sortColumn"
|
||||
v-model:sort-direction="sortDirection"
|
||||
class="audit-log-table hidden @[800px]:block"
|
||||
:columns="columns"
|
||||
:data="tableEntries"
|
||||
row-key="id"
|
||||
:row-transition-name="rowTransitionName"
|
||||
>
|
||||
<template #header-world="{ column }">
|
||||
<span class="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold">
|
||||
<span class="min-w-0 truncate">{{ column.label }}</span>
|
||||
<Tooltip
|
||||
theme="dismissable-prompt"
|
||||
class="inline-flex shrink-0"
|
||||
:triggers="['hover', 'focus']"
|
||||
:popper-triggers="['hover', 'focus']"
|
||||
popper-class="v-popper--interactive"
|
||||
placement="top"
|
||||
:delay="{ show: 200, hide: 100 }"
|
||||
no-auto-focus
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="formatMessage(messages.instanceTooltipTitle)"
|
||||
class="inline-flex cursor-help items-center justify-center border-0 bg-transparent p-0 text-secondary transition-colors hover:text-contrast"
|
||||
>
|
||||
<UnknownIcon class="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
<template #popper>
|
||||
<div class="grid !w-64 gap-1">
|
||||
<h3 class="m-0 whitespace-nowrap text-base w-full font-bold text-contrast">
|
||||
{{ formatMessage(messages.instanceTooltipTitle) }}
|
||||
</h3>
|
||||
<p
|
||||
class="m-0 text-wrap text-sm w-full font-medium leading-tight text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.instanceTooltipDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-user="{ row: entry }">
|
||||
<AutoLink
|
||||
v-tooltip="actorName(entry)"
|
||||
:to="actorProfilePath(entry)"
|
||||
class="flex min-w-0 items-center gap-2 whitespace-nowrap"
|
||||
:class="actorProfilePath(entry) ? 'text-primary hover:underline' : ''"
|
||||
>
|
||||
<Avatar
|
||||
:src="
|
||||
entry.actor.id === 'support'
|
||||
? IntercomBubbleIcon
|
||||
: (entry.actor.avatarUrl ?? undefined)
|
||||
"
|
||||
:alt="formatMessage(messages.userAvatarAlt, { username: actorName(entry) })"
|
||||
:tint-by="entry.actor.username"
|
||||
size="22px"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<span
|
||||
class="min-w-0 truncate font-medium"
|
||||
:class="entry.actor.id === 'support' ? 'text-blue' : ''"
|
||||
>
|
||||
{{ actorName(entry) }}
|
||||
</span>
|
||||
</AutoLink>
|
||||
</template>
|
||||
|
||||
<template #cell-event="{ row: entry }">
|
||||
<AuditLogEventCell :event="entry.event" />
|
||||
</template>
|
||||
|
||||
<template #cell-world="{ row: entry }">
|
||||
<span
|
||||
v-tooltip="entry.world?.name"
|
||||
class="block min-w-0 truncate whitespace-nowrap"
|
||||
:class="entry.world ? 'text-primary' : 'text-secondary'"
|
||||
>
|
||||
{{ entry.world?.name ?? '—' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-time="{ row: entry }">
|
||||
<span
|
||||
v-tooltip="formatDate(entry.timestamp)"
|
||||
class="inline-block whitespace-nowrap align-middle leading-6"
|
||||
>
|
||||
{{ formatRelativeTime(entry.timestamp) }}
|
||||
</span>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<TransitionGroup
|
||||
v-if="filteredEntries.length > 0"
|
||||
name="audit-log-card"
|
||||
tag="div"
|
||||
class="flex flex-col gap-3 @[800px]:hidden"
|
||||
>
|
||||
<div
|
||||
v-for="entry in filteredEntries"
|
||||
:key="entry.id"
|
||||
class="flex min-w-0 flex-col gap-3 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-4"
|
||||
>
|
||||
<AutoLink
|
||||
v-tooltip="actorName(entry)"
|
||||
:to="actorProfilePath(entry)"
|
||||
class="inline-flex min-w-0 items-center gap-2 self-start"
|
||||
:class="actorProfilePath(entry) ? 'text-primary hover:underline' : 'text-primary'"
|
||||
>
|
||||
<Avatar
|
||||
:src="actorAvatarSrc(entry)"
|
||||
:alt="formatMessage(messages.userAvatarAlt, { username: actorName(entry) })"
|
||||
:tint-by="entry.actor.username"
|
||||
size="24px"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<span
|
||||
class="min-w-0 truncate font-medium"
|
||||
:class="entry.actor.id === 'support' ? 'text-blue' : ''"
|
||||
>
|
||||
{{ actorName(entry) }}
|
||||
</span>
|
||||
</AutoLink>
|
||||
<div class="min-w-0">
|
||||
<component :is="entry.event.component" v-bind="entry.event.props" />
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-1 text-sm text-secondary">
|
||||
<span v-if="showWorldColumn" v-tooltip="entry.world?.name" class="min-w-0 truncate">
|
||||
{{ entry.world?.name ?? formatMessage(messages.serverScope) }}
|
||||
</span>
|
||||
<BulletDivider v-if="showWorldColumn" class="shrink-0" />
|
||||
<span v-tooltip="formatDate(entry.timestamp)" class="shrink-0">
|
||||
{{ formatRelativeTime(entry.timestamp) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<div v-else class="overflow-hidden rounded-2xl border border-solid border-surface-5">
|
||||
<div
|
||||
class="hidden min-h-14 bg-surface-3 @[800px]:grid @[800px]:h-14"
|
||||
:class="
|
||||
showWorldColumn
|
||||
? '@[800px]:grid-cols-[18%_52%_20%_10%]'
|
||||
: '@[800px]:grid-cols-[26%_58%_16%]'
|
||||
"
|
||||
>
|
||||
<div class="hidden items-center pl-4 pr-2 font-semibold text-secondary @[800px]:flex">
|
||||
{{ formatMessage(messages.userColumn) }}
|
||||
</div>
|
||||
<div class="hidden items-center px-2 font-semibold text-secondary @[800px]:flex">
|
||||
{{ formatMessage(messages.eventColumn) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="showWorldColumn"
|
||||
class="hidden items-center px-2 font-semibold text-secondary @[800px]:flex"
|
||||
>
|
||||
<span class="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold">
|
||||
<span class="min-w-0 truncate">{{ formatMessage(messages.worldColumn) }}</span>
|
||||
<Tooltip
|
||||
theme="dismissable-prompt"
|
||||
class="inline-flex shrink-0"
|
||||
:triggers="['hover', 'focus']"
|
||||
:popper-triggers="['hover', 'focus']"
|
||||
popper-class="v-popper--interactive"
|
||||
placement="top"
|
||||
:delay="{ show: 200, hide: 100 }"
|
||||
no-auto-focus
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="formatMessage(messages.instanceTooltipTitle)"
|
||||
class="inline-flex cursor-help items-center justify-center border-0 bg-transparent p-0 text-secondary transition-colors hover:text-contrast"
|
||||
>
|
||||
<UnknownIcon class="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
<template #popper>
|
||||
<div class="grid !w-64 gap-1">
|
||||
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
|
||||
{{ formatMessage(messages.instanceTooltipTitle) }}
|
||||
</h3>
|
||||
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
||||
{{ formatMessage(messages.instanceTooltipDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="hidden items-center justify-end pl-2 pr-4 font-semibold text-secondary @[800px]:flex"
|
||||
>
|
||||
{{ formatMessage(messages.timeColumn) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-0 border-solid border-surface-5 bg-surface-2 px-4 py-8 text-center text-secondary @[800px]:border-t"
|
||||
>
|
||||
{{ formatMessage(emptyStateMessage) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="audit-log-loading-fade">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="pointer-events-none absolute bottom-px left-px right-px top-0 z-20 animate-audit-log-bpulse rounded-[15px] bg-surface-3 @[800px]:top-[57px] @[800px]:rounded-t-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loadingMore"
|
||||
class="h-8 animate-audit-log-bpulse rounded-xl bg-surface-3"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div v-if="hasMore" ref="loadMoreSentinel" class="h-px"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IntercomBubbleIcon, UnknownIcon } from '@modrinth/assets'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch } from 'vue'
|
||||
|
||||
import { useFormatDateTime, useRelativeTime } from '../../../composables'
|
||||
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
||||
import AutoLink from '../../base/AutoLink.vue'
|
||||
import Avatar from '../../base/Avatar.vue'
|
||||
import BulletDivider from '../../base/BulletDivider.vue'
|
||||
import Table, { type SortDirection, type TableColumn } from '../../base/Table.vue'
|
||||
import TimeFramePicker, {
|
||||
type TimeFrameLastUnit,
|
||||
type TimeFrameMode,
|
||||
type TimeFramePreset,
|
||||
} from '../../base/TimeFramePicker.vue'
|
||||
import AuditLogEventCell from './AuditLogEventCell.vue'
|
||||
import type { ServerAuditLogEntry, ServerAuditLogFilters } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
entries: ServerAuditLogEntry[]
|
||||
hasActiveExternalFilters?: boolean
|
||||
hasMore?: boolean
|
||||
loading?: boolean
|
||||
loadingMore?: boolean
|
||||
showWorldColumn?: boolean
|
||||
suppressRowTransitions?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'load-more': []
|
||||
}>()
|
||||
|
||||
const query = defineModel<string>('query', { default: '' })
|
||||
const timeframeMode = defineModel<TimeFrameMode>('timeframeMode', { default: 'preset' })
|
||||
const timeframePreset = defineModel<TimeFramePreset>('timeframePreset', { default: 'all_time' })
|
||||
const timeframeLastAmount = defineModel<number>('timeframeLastAmount', { default: 30 })
|
||||
const timeframeLastUnit = defineModel<TimeFrameLastUnit>('timeframeLastUnit', { default: 'days' })
|
||||
const timeframeCustomStartDate = defineModel<string>('timeframeCustomStartDate', { default: '' })
|
||||
const timeframeCustomEndDate = defineModel<string>('timeframeCustomEndDate', { default: '' })
|
||||
const sortDirection = defineModel<SortDirection>('sortDirection', { default: 'desc' })
|
||||
const filters = defineModel<ServerAuditLogFilters>('filters', {
|
||||
default: () => ({
|
||||
userId: null,
|
||||
worldId: null,
|
||||
}),
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const formatDate = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
|
||||
const slots = useSlots()
|
||||
const sortColumn = ref<string | undefined>('time')
|
||||
const suppressSortRowTransitions = ref(false)
|
||||
const loadMoreSentinel = ref<HTMLElement | null>(null)
|
||||
const contentBody = ref<HTMLElement | null>(null)
|
||||
const contentHeight = ref<number | null>(null)
|
||||
const showWorldColumn = computed(() => props.showWorldColumn === true)
|
||||
let loadMoreObserver: IntersectionObserver | null = null
|
||||
let contentResizeObserver: ResizeObserver | null = null
|
||||
let sortTransitionResetTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const messages = defineMessages({
|
||||
supportActor: {
|
||||
id: 'servers.audit-log.actor.support',
|
||||
defaultMessage: 'Support',
|
||||
},
|
||||
userColumn: {
|
||||
id: 'servers.audit-log.column.user',
|
||||
defaultMessage: 'User',
|
||||
},
|
||||
worldColumn: {
|
||||
id: 'servers.audit-log.column.world',
|
||||
defaultMessage: 'Instance',
|
||||
},
|
||||
instanceTooltipTitle: {
|
||||
id: 'servers.audit-log.column.world.tooltip-title',
|
||||
defaultMessage: 'Coming soon!',
|
||||
},
|
||||
instanceTooltipDescription: {
|
||||
id: 'servers.audit-log.column.world.tooltip-description',
|
||||
defaultMessage:
|
||||
'Server instances are contained environments with their own installed content and world files.',
|
||||
},
|
||||
eventColumn: {
|
||||
id: 'servers.audit-log.column.event',
|
||||
defaultMessage: 'Actions',
|
||||
},
|
||||
timeColumn: {
|
||||
id: 'servers.audit-log.column.time',
|
||||
defaultMessage: 'Time',
|
||||
},
|
||||
emptyState: {
|
||||
id: 'servers.audit-log.empty',
|
||||
defaultMessage: 'No activity matches your filters.',
|
||||
},
|
||||
noActivityEmptyState: {
|
||||
id: 'servers.audit-log.empty.no-activity',
|
||||
defaultMessage: 'Perform an action on your server and you will see it here!',
|
||||
},
|
||||
userAvatarAlt: {
|
||||
id: 'servers.audit-log.user-avatar-alt',
|
||||
defaultMessage: "{username}'s avatar",
|
||||
},
|
||||
serverScope: {
|
||||
id: 'servers.audit-log.scope.server',
|
||||
defaultMessage: 'Server',
|
||||
},
|
||||
})
|
||||
|
||||
const timeframePickerClass = computed(() =>
|
||||
slots.filters ? '!w-full @[640px]:!w-[225px] shrink-0' : '!w-full @[640px]:!w-[225px]',
|
||||
)
|
||||
const timeframePickerTriggerClass =
|
||||
'!h-10 !min-h-10 !w-full !rounded-[14px] !bg-surface-4 !py-2.5 !pl-4 !pr-3 !text-base shadow-[0px_1px_1px_rgba(0,0,0,0.3),0px_1px_1.5px_rgba(0,0,0,0.15)]'
|
||||
|
||||
onMounted(() => {
|
||||
updateLoadMoreObserver()
|
||||
updateContentHeightObserver()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
loadMoreObserver?.disconnect()
|
||||
contentResizeObserver?.disconnect()
|
||||
if (sortTransitionResetTimeout) {
|
||||
clearTimeout(sortTransitionResetTimeout)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.hasMore, props.loadingMore, loadMoreSentinel.value] as const,
|
||||
() => updateLoadMoreObserver(),
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
watch(
|
||||
sortDirection,
|
||||
(_direction, previousDirection) => {
|
||||
if (previousDirection === undefined) return
|
||||
suppressSortRowTransitions.value = true
|
||||
scheduleSortTransitionReset(1500)
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.entries,
|
||||
() => {
|
||||
if (suppressSortRowTransitions.value) {
|
||||
scheduleSortTransitionReset(120)
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
type AuditLogTableColumn = 'user' | 'event' | 'world' | 'time'
|
||||
type AuditLogTableRow = ServerAuditLogEntry & Record<string, unknown>
|
||||
|
||||
const columns = computed<TableColumn<AuditLogTableColumn>[]>(() => {
|
||||
const tableColumns: TableColumn<AuditLogTableColumn>[] = [
|
||||
{
|
||||
key: 'user',
|
||||
label: formatMessage(messages.userColumn),
|
||||
width: showWorldColumn.value ? '18%' : '26%',
|
||||
},
|
||||
{
|
||||
key: 'event',
|
||||
label: formatMessage(messages.eventColumn),
|
||||
width: showWorldColumn.value ? '52%' : '58%',
|
||||
},
|
||||
]
|
||||
|
||||
if (showWorldColumn.value) {
|
||||
tableColumns.push({
|
||||
key: 'world',
|
||||
label: formatMessage(messages.worldColumn),
|
||||
width: '20%',
|
||||
})
|
||||
}
|
||||
|
||||
tableColumns.push({
|
||||
key: 'time',
|
||||
label: formatMessage(messages.timeColumn),
|
||||
align: 'right',
|
||||
enableSorting: true,
|
||||
width: showWorldColumn.value ? '10%' : '16%',
|
||||
})
|
||||
|
||||
return tableColumns
|
||||
})
|
||||
const rowTransitionName = computed(() =>
|
||||
props.suppressRowTransitions || suppressSortRowTransitions.value ? undefined : 'audit-log-row',
|
||||
)
|
||||
|
||||
const filteredEntries = computed(() => {
|
||||
const normalizedQuery = query.value.trim().toLowerCase()
|
||||
|
||||
return props.entries
|
||||
.filter((entry) => {
|
||||
if (filters.value.userId && entry.actor.id !== filters.value.userId) return false
|
||||
if (
|
||||
showWorldColumn.value &&
|
||||
filters.value.worldId &&
|
||||
entry.world?.id !== filters.value.worldId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!normalizedQuery) return true
|
||||
|
||||
return [
|
||||
entry.actor.username,
|
||||
showWorldColumn.value ? entry.world?.name : undefined,
|
||||
entry.event.searchText,
|
||||
entry.event.key,
|
||||
]
|
||||
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
.some((value) => value.toLowerCase().includes(normalizedQuery))
|
||||
})
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const leftTimestamp = new Date(a.timestamp).getTime()
|
||||
const rightTimestamp = new Date(b.timestamp).getTime()
|
||||
return sortDirection.value === 'asc'
|
||||
? leftTimestamp - rightTimestamp
|
||||
: rightTimestamp - leftTimestamp
|
||||
})
|
||||
})
|
||||
|
||||
const tableEntries = computed<AuditLogTableRow[]>(() => filteredEntries.value as AuditLogTableRow[])
|
||||
const contentFrameStyle = computed(() =>
|
||||
contentHeight.value === null ? undefined : { height: `${contentHeight.value}px` },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [filteredEntries.value.length, props.loading] as const,
|
||||
() => updateContentHeight(),
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
const hasActiveTimeframeFilter = computed(() => {
|
||||
if (timeframeMode.value === 'preset') {
|
||||
return timeframePreset.value !== 'all_time'
|
||||
}
|
||||
|
||||
if (timeframeMode.value === 'last') {
|
||||
return true
|
||||
}
|
||||
|
||||
return Boolean(timeframeCustomStartDate.value || timeframeCustomEndDate.value)
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(
|
||||
() =>
|
||||
props.hasActiveExternalFilters ||
|
||||
query.value.trim().length > 0 ||
|
||||
hasActiveTimeframeFilter.value ||
|
||||
!!filters.value.userId ||
|
||||
(showWorldColumn.value && !!filters.value.worldId),
|
||||
)
|
||||
|
||||
const emptyStateMessage = computed(() =>
|
||||
props.entries.length === 0 && !hasActiveFilters.value
|
||||
? messages.noActivityEmptyState
|
||||
: messages.emptyState,
|
||||
)
|
||||
|
||||
function actorName(entry: ServerAuditLogEntry): string {
|
||||
if (entry.actor.id !== 'support') return entry.actor.username
|
||||
return entry.actor.username === 'support'
|
||||
? formatMessage(messages.supportActor)
|
||||
: entry.actor.username
|
||||
}
|
||||
|
||||
function actorAvatarSrc(entry: ServerAuditLogEntry): string | undefined {
|
||||
return entry.actor.id === 'support' ? IntercomBubbleIcon : (entry.actor.avatarUrl ?? undefined)
|
||||
}
|
||||
|
||||
function actorProfilePath(entry: ServerAuditLogEntry): string | undefined {
|
||||
return entry.actor.profilePath
|
||||
}
|
||||
|
||||
function updateLoadMoreObserver() {
|
||||
loadMoreObserver?.disconnect()
|
||||
loadMoreObserver = null
|
||||
|
||||
if (!props.hasMore || typeof IntersectionObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
if (!loadMoreSentinel.value || !props.hasMore) {
|
||||
return
|
||||
}
|
||||
|
||||
loadMoreObserver?.disconnect()
|
||||
loadMoreObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting) && !props.loadingMore) {
|
||||
emit('load-more')
|
||||
}
|
||||
},
|
||||
{ rootMargin: '400px 0px' },
|
||||
)
|
||||
loadMoreObserver.observe(loadMoreSentinel.value)
|
||||
})
|
||||
}
|
||||
|
||||
function updateContentHeightObserver() {
|
||||
contentResizeObserver?.disconnect()
|
||||
contentResizeObserver = null
|
||||
|
||||
nextTick(() => {
|
||||
updateContentHeight()
|
||||
|
||||
if (!contentBody.value || typeof ResizeObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
contentResizeObserver = new ResizeObserver((entries) => {
|
||||
const height =
|
||||
entries[0]?.contentRect.height ?? contentBody.value?.getBoundingClientRect().height ?? 0
|
||||
setContentHeight(height)
|
||||
})
|
||||
contentResizeObserver.observe(contentBody.value)
|
||||
})
|
||||
}
|
||||
|
||||
function updateContentHeight() {
|
||||
nextTick(() => {
|
||||
if (!contentBody.value) return
|
||||
setContentHeight(contentBody.value.getBoundingClientRect().height)
|
||||
})
|
||||
}
|
||||
|
||||
function setContentHeight(height: number) {
|
||||
contentHeight.value = Math.ceil(height)
|
||||
}
|
||||
|
||||
function scheduleSortTransitionReset(delay: number) {
|
||||
if (sortTransitionResetTimeout) {
|
||||
clearTimeout(sortTransitionResetTimeout)
|
||||
}
|
||||
|
||||
sortTransitionResetTimeout = setTimeout(() => {
|
||||
suppressSortRowTransitions.value = false
|
||||
sortTransitionResetTimeout = null
|
||||
}, delay)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.audit-log-content-frame {
|
||||
transition: height 220ms ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes audit-log-bpulse {
|
||||
50% {
|
||||
filter: brightness(75%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-audit-log-bpulse {
|
||||
animation: audit-log-bpulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.audit-log-table :is(th, td) {
|
||||
height: 54px;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.audit-log-table :is(th, td):first-child {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.audit-log-table :is(th, td):last-child {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.audit-log-table tbody tr {
|
||||
height: 54px;
|
||||
max-height: 54px;
|
||||
}
|
||||
|
||||
.audit-log-table tbody td {
|
||||
height: 54px;
|
||||
max-height: 54px;
|
||||
padding-bottom: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.audit-log-table-event {
|
||||
line-height: 1.375rem;
|
||||
}
|
||||
|
||||
.audit-log-table-event-component > span {
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
@container (min-width: 1040px) {
|
||||
.audit-log-table :is(th, td) {
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.audit-log-table :is(th, td):first-child {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.audit-log-table :is(th, td):last-child {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.audit-log-table-event {
|
||||
line-height: 1.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
.audit-log-loading-fade-enter-active,
|
||||
.audit-log-loading-fade-leave-active {
|
||||
transition: opacity 250ms ease-in-out;
|
||||
}
|
||||
|
||||
.audit-log-loading-fade-enter-from,
|
||||
.audit-log-loading-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.audit-log-row-enter-active,
|
||||
.audit-log-row-leave-active,
|
||||
.audit-log-row-move,
|
||||
.audit-log-card-enter-active,
|
||||
.audit-log-card-leave-active,
|
||||
.audit-log-card-move {
|
||||
transition:
|
||||
opacity 180ms ease-in-out,
|
||||
transform 180ms ease-in-out;
|
||||
}
|
||||
|
||||
.audit-log-row-enter-from,
|
||||
.audit-log-card-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.audit-log-row-leave-to,
|
||||
.audit-log-card-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,459 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header)"
|
||||
width="min(34rem, calc(100vw - 2rem))"
|
||||
max-width="34rem"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-semibold text-contrast" for="grant-access-target">
|
||||
{{ formatMessage(messages.targetLabel) }}
|
||||
</label>
|
||||
<Combobox
|
||||
id="grant-access-target"
|
||||
:model-value="undefined"
|
||||
:options="suggestionOptions"
|
||||
:search-placeholder="formatMessage(messages.targetPlaceholder)"
|
||||
:placeholder="formatMessage(messages.targetPlaceholder)"
|
||||
:no-options-message="targetLookupMessage"
|
||||
:min-search-length-to-open="suggestionMinimumLength"
|
||||
:disable-search-filter="usesRemoteLookup"
|
||||
searchable
|
||||
show-search-icon
|
||||
:show-chevron="false"
|
||||
search-autocomplete="off"
|
||||
search-autocorrect="off"
|
||||
search-autocapitalize="none"
|
||||
:search-spellcheck="false"
|
||||
@open="targetComboboxOpen = true"
|
||||
@close="targetComboboxOpen = false"
|
||||
@search-input="handleTargetSearch"
|
||||
@select="handleTargetSelect"
|
||||
>
|
||||
<template #option="{ item, isSelected }">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Avatar
|
||||
:src="findSuggestion(item.value)?.avatarUrl"
|
||||
:alt="formatMessage(messages.suggestionAvatarAlt, { username: item.label })"
|
||||
:tint-by="item.label"
|
||||
size="1.5rem"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<span
|
||||
class="min-w-0 truncate font-semibold"
|
||||
:class="isSelected ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Combobox>
|
||||
<span class="m-0 text-base text-primary">
|
||||
{{ formatMessage(messages.targetHelp) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.roleLabel) }}
|
||||
</span>
|
||||
<div class="flex flex-col gap-3">
|
||||
<button
|
||||
v-for="role in grantableRoles"
|
||||
:key="role.value"
|
||||
type="button"
|
||||
class="group flex w-full items-center gap-3 rounded-[20px] border border-solid p-3 text-left transition-all hover:brightness-110 active:scale-[0.98]"
|
||||
:class="
|
||||
selectedRole === role.value
|
||||
? 'border-brand bg-brand-highlight'
|
||||
: 'border-transparent bg-surface-4'
|
||||
"
|
||||
@click="selectedRole = role.value"
|
||||
>
|
||||
<span
|
||||
class="flex size-12 shrink-0 items-center justify-center rounded-2xl border border-solid"
|
||||
:class="
|
||||
selectedRole === role.value
|
||||
? 'border-brand bg-brand-highlight text-brand'
|
||||
: 'border-surface-5 text-secondary'
|
||||
"
|
||||
>
|
||||
<component :is="role.icon" class="size-8" stroke-width="1.5" aria-hidden="true" />
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="text-base font-semibold text-contrast">{{ role.label }}</span>
|
||||
<span class="text-sm font-medium text-primary">{{ role.description }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="m-0 text-base text-primary">
|
||||
<IntlFormatted :message-id="messages.permissionsHelp">
|
||||
<template #link="{ children }">
|
||||
<a
|
||||
class="font-medium text-blue hover:underline"
|
||||
href="/news/article/server-access/"
|
||||
target="_blank"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-20"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-20"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<Checkbox
|
||||
v-if="showAddAsFriend"
|
||||
v-model="addAsFriend"
|
||||
:label="formatMessage(messages.addAsFriend)"
|
||||
label-class="text-base text-contrast"
|
||||
class="mt-2"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="hide">
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button v-tooltip="grantPermissionTooltip" :disabled="!canSubmit" @click="submit">
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.inviteButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EyeIcon, PencilIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
||||
import { commonMessages } from '../../../utils/common-messages'
|
||||
import Avatar from '../../base/Avatar.vue'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import Checkbox from '../../base/Checkbox.vue'
|
||||
import Combobox, { type ComboboxOption } from '../../base/Combobox.vue'
|
||||
import IntlFormatted from '../../base/IntlFormatted.vue'
|
||||
import NewModal from '../../modal/NewModal.vue'
|
||||
import type {
|
||||
GrantServerAccessPayload,
|
||||
ServerAccessInviteSuggestion,
|
||||
ServerAccessMember,
|
||||
ServerAccessRole,
|
||||
} from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
members?: ServerAccessMember[]
|
||||
suggestions?: ServerAccessInviteSuggestion[]
|
||||
friendIds?: string[]
|
||||
searchUsers?: (query: string) => Promise<ServerAccessInviteSuggestion[]>
|
||||
canGrant?: boolean
|
||||
permissionDeniedMessage?: string
|
||||
}>(),
|
||||
{
|
||||
members: () => [],
|
||||
suggestions: () => [],
|
||||
friendIds: () => [],
|
||||
canGrant: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
grant: [payload: GrantServerAccessPayload]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const target = ref('')
|
||||
const selectedRole = ref<Exclude<ServerAccessRole, 'owner'>>('editor')
|
||||
const addAsFriend = ref(true)
|
||||
const suggestionMinimumLength = 1
|
||||
const remoteSuggestions = ref<ServerAccessInviteSuggestion[]>([])
|
||||
const targetLookupStatus = ref<'idle' | 'loading' | 'loaded'>('idle')
|
||||
const targetLookupRequestId = ref(0)
|
||||
const hasSelectedTarget = ref(false)
|
||||
const targetComboboxOpen = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'servers.grant-access-modal.header',
|
||||
defaultMessage: 'Add user',
|
||||
},
|
||||
targetLabel: {
|
||||
id: 'servers.grant-access-modal.target.label',
|
||||
defaultMessage: 'Modrinth username',
|
||||
},
|
||||
targetPlaceholder: {
|
||||
id: 'servers.grant-access-modal.target.placeholder',
|
||||
defaultMessage: 'Enter Modrinth username',
|
||||
},
|
||||
noSuggestions: {
|
||||
id: 'servers.grant-access-modal.target.no-suggestions',
|
||||
defaultMessage: 'No matching users found.',
|
||||
},
|
||||
searching: {
|
||||
id: 'servers.grant-access-modal.target.searching',
|
||||
defaultMessage: 'Searching...',
|
||||
},
|
||||
targetHelp: {
|
||||
id: 'servers.grant-access-modal.target.help',
|
||||
defaultMessage: 'Do not use their Minecraft username.',
|
||||
},
|
||||
roleLabel: {
|
||||
id: 'servers.grant-access-modal.role.label',
|
||||
defaultMessage: 'Select role',
|
||||
},
|
||||
editorRole: {
|
||||
id: 'servers.grant-access-modal.role.editor',
|
||||
defaultMessage: 'Editor',
|
||||
},
|
||||
editorDescription: {
|
||||
id: 'servers.grant-access-modal.role.editor-description',
|
||||
defaultMessage: 'Manage instance content, files, backups, and other settings.',
|
||||
},
|
||||
viewerRole: {
|
||||
id: 'servers.grant-access-modal.role.viewer',
|
||||
defaultMessage: 'Limited',
|
||||
},
|
||||
viewerDescription: {
|
||||
id: 'servers.grant-access-modal.role.viewer-description',
|
||||
defaultMessage: 'Start, stop, and view the server without making changes.',
|
||||
},
|
||||
permissionsHelp: {
|
||||
id: 'servers.grant-access-modal.permissions-help',
|
||||
defaultMessage: 'View the full list of permissions for each role <link>here</link>.',
|
||||
},
|
||||
addAsFriend: {
|
||||
id: 'servers.grant-access-modal.add-as-friend',
|
||||
defaultMessage: 'Also send a friend request',
|
||||
},
|
||||
cancelButton: {
|
||||
id: 'servers.grant-access-modal.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
inviteButton: {
|
||||
id: 'servers.grant-access-modal.invite',
|
||||
defaultMessage: 'Invite',
|
||||
},
|
||||
suggestionAvatarAlt: {
|
||||
id: 'servers.grant-access-modal.suggestion-avatar-alt',
|
||||
defaultMessage: "{username}'s avatar",
|
||||
},
|
||||
alreadyMemberTooltip: {
|
||||
id: 'servers.grant-access-modal.already-member-tooltip',
|
||||
defaultMessage: 'This user has already been invited',
|
||||
},
|
||||
})
|
||||
|
||||
const grantableRoles = computed(() => [
|
||||
{
|
||||
value: 'editor' as const,
|
||||
label: formatMessage(messages.editorRole),
|
||||
description: formatMessage(messages.editorDescription),
|
||||
icon: PencilIcon,
|
||||
},
|
||||
{
|
||||
value: 'viewer' as const,
|
||||
label: formatMessage(messages.viewerRole),
|
||||
description: formatMessage(messages.viewerDescription),
|
||||
icon: EyeIcon,
|
||||
},
|
||||
])
|
||||
|
||||
const normalizedTarget = computed(() => target.value.trim())
|
||||
const usesRemoteLookup = computed(() => !!props.searchUsers)
|
||||
const matchedSuggestion = computed(() => findSuggestion(normalizedTarget.value))
|
||||
const selectedTargetUserId = computed(() => matchedSuggestion.value?.id)
|
||||
const friendIdSet = computed(() => new Set(props.friendIds.map((id) => id.toLowerCase())))
|
||||
const targetIsFriend = computed(() => {
|
||||
const userId = selectedTargetUserId.value
|
||||
return !!userId && friendIdSet.value.has(userId.toLowerCase())
|
||||
})
|
||||
const hasResolvedTarget = computed(() => {
|
||||
const suggestion = matchedSuggestion.value
|
||||
if (!suggestion) return false
|
||||
|
||||
const normalizedSuggestionId = suggestion.id.toLowerCase()
|
||||
const normalizedSuggestionUsername = suggestion.username.toLowerCase()
|
||||
const normalizedValue = normalizedTarget.value.toLowerCase()
|
||||
|
||||
return (
|
||||
hasSelectedTarget.value ||
|
||||
normalizedSuggestionId === normalizedValue ||
|
||||
normalizedSuggestionUsername === normalizedValue
|
||||
)
|
||||
})
|
||||
const showAddAsFriend = computed(() => canAddAsFriend.value && !targetComboboxOpen.value)
|
||||
const canAddAsFriend = computed(
|
||||
() => hasResolvedTarget.value && !!matchedSuggestion.value && !targetIsFriend.value,
|
||||
)
|
||||
const existingMember = computed(() => findExistingMember())
|
||||
const canInvite = computed(
|
||||
() =>
|
||||
normalizedTarget.value.length > 0 &&
|
||||
!!selectedRole.value &&
|
||||
!!matchedSuggestion.value &&
|
||||
!existingMember.value &&
|
||||
(!usesRemoteLookup.value || (targetLookupStatus.value === 'loaded' && hasResolvedTarget.value)),
|
||||
)
|
||||
const canSubmit = computed(() => props.canGrant && canInvite.value)
|
||||
const permissionDeniedMessage = computed(
|
||||
() => props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction),
|
||||
)
|
||||
const grantPermissionTooltip = computed(() => {
|
||||
if (!props.canGrant) return permissionDeniedMessage.value
|
||||
if (existingMember.value) return formatMessage(messages.alreadyMemberTooltip)
|
||||
return undefined
|
||||
})
|
||||
const targetLookupMessage = computed(() =>
|
||||
usesRemoteLookup.value && targetLookupStatus.value !== 'loaded'
|
||||
? formatMessage(messages.searching)
|
||||
: formatMessage(messages.noSuggestions),
|
||||
)
|
||||
const inviteSuggestions = computed(() => {
|
||||
const suggestions = new Map<string, ServerAccessInviteSuggestion>()
|
||||
|
||||
for (const suggestion of [...remoteSuggestions.value, ...props.suggestions]) {
|
||||
suggestions.set(suggestion.id.toLowerCase(), suggestion)
|
||||
suggestions.set(suggestion.username.toLowerCase(), suggestion)
|
||||
}
|
||||
|
||||
return [...new Set(suggestions.values())]
|
||||
})
|
||||
const suggestionOptions = computed<ComboboxOption<string>[]>(() =>
|
||||
inviteSuggestions.value.map((suggestion) => ({
|
||||
value: suggestion.username,
|
||||
label: suggestion.username,
|
||||
searchTerms: [suggestion.username, suggestion.id, suggestion.email].filter(Boolean) as string[],
|
||||
})),
|
||||
)
|
||||
|
||||
function findSuggestion(value: string) {
|
||||
const normalizedValue = value.trim().toLowerCase()
|
||||
return inviteSuggestions.value.find(
|
||||
(suggestion) =>
|
||||
suggestion.username.toLowerCase() === normalizedValue ||
|
||||
suggestion.id.toLowerCase() === normalizedValue ||
|
||||
suggestion.email?.toLowerCase() === normalizedValue,
|
||||
)
|
||||
}
|
||||
|
||||
function findExistingMember() {
|
||||
const normalizedValue = normalizedTarget.value.toLowerCase()
|
||||
if (!normalizedValue) return undefined
|
||||
|
||||
const suggestion = matchedSuggestion.value
|
||||
const normalizedSuggestionId = suggestion?.id.toLowerCase()
|
||||
const normalizedSuggestionUsername = suggestion?.username.toLowerCase()
|
||||
|
||||
return props.members.find((member) => {
|
||||
const normalizedMemberId = member.user.id.toLowerCase()
|
||||
const normalizedMemberUsername = member.user.username.toLowerCase()
|
||||
|
||||
return (
|
||||
normalizedMemberId === normalizedValue ||
|
||||
normalizedMemberUsername === normalizedValue ||
|
||||
(!!normalizedSuggestionId && normalizedMemberId === normalizedSuggestionId) ||
|
||||
(!!normalizedSuggestionUsername && normalizedMemberUsername === normalizedSuggestionUsername)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const searchTargetUsers = useDebounceFn(async (query: string, requestId: number) => {
|
||||
const searchUsers = props.searchUsers
|
||||
if (!searchUsers) return
|
||||
|
||||
try {
|
||||
const users = await searchUsers(query)
|
||||
if (requestId !== targetLookupRequestId.value || query !== normalizedTarget.value) return
|
||||
|
||||
remoteSuggestions.value = users
|
||||
} catch {
|
||||
if (requestId !== targetLookupRequestId.value || query !== normalizedTarget.value) return
|
||||
|
||||
remoteSuggestions.value = []
|
||||
} finally {
|
||||
if (requestId === targetLookupRequestId.value && query === normalizedTarget.value) {
|
||||
targetLookupStatus.value = 'loaded'
|
||||
}
|
||||
}
|
||||
}, 250)
|
||||
|
||||
function handleTargetSearch(value: string) {
|
||||
target.value = value
|
||||
remoteSuggestions.value = []
|
||||
hasSelectedTarget.value = false
|
||||
targetLookupRequestId.value += 1
|
||||
|
||||
if (!usesRemoteLookup.value) return
|
||||
|
||||
if (normalizedTarget.value.length < suggestionMinimumLength) {
|
||||
targetLookupStatus.value = 'idle'
|
||||
return
|
||||
}
|
||||
|
||||
targetLookupStatus.value = 'loading'
|
||||
void searchTargetUsers(normalizedTarget.value, targetLookupRequestId.value)
|
||||
}
|
||||
|
||||
function handleTargetSelect(option: ComboboxOption<string>) {
|
||||
target.value = option.value
|
||||
hasSelectedTarget.value = true
|
||||
}
|
||||
|
||||
function reset() {
|
||||
target.value = ''
|
||||
selectedRole.value = 'editor'
|
||||
addAsFriend.value = true
|
||||
remoteSuggestions.value = []
|
||||
targetLookupStatus.value = 'idle'
|
||||
targetLookupRequestId.value += 1
|
||||
hasSelectedTarget.value = false
|
||||
targetComboboxOpen.value = false
|
||||
}
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
reset()
|
||||
modal.value?.show(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!canSubmit.value) return
|
||||
const user = matchedSuggestion.value
|
||||
if (!user) return
|
||||
|
||||
const payload: GrantServerAccessPayload = {
|
||||
target: normalizedTarget.value,
|
||||
user,
|
||||
role: selectedRole.value,
|
||||
addAsFriend: canAddAsFriend.value && addAsFriend.value,
|
||||
}
|
||||
|
||||
hide()
|
||||
emit('grant', payload)
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="
|
||||
formatMessage(modalState.shouldCancel ? messages.cancelHeader : messages.header, {
|
||||
username: modalState.username,
|
||||
})
|
||||
"
|
||||
max-width="470px"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Admonition type="warning">
|
||||
{{
|
||||
formatMessage(
|
||||
modalState.shouldCancel ? messages.cancelWarningBody : messages.warningBody,
|
||||
{
|
||||
username: modalState.username,
|
||||
},
|
||||
)
|
||||
}}
|
||||
</Admonition>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-2 rounded-[20px] bg-surface-2 p-3">
|
||||
<Avatar
|
||||
:src="modalState.avatarUrl"
|
||||
:alt="formatMessage(messages.userAvatarAlt, { username: modalState.username })"
|
||||
:tint-by="modalState.username"
|
||||
size="40px"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<span class="min-w-0 truncate font-medium text-contrast">{{
|
||||
modalState.username
|
||||
}}</span>
|
||||
<span
|
||||
v-if="memberStatusLabel"
|
||||
class="inline-flex h-6 shrink-0 items-center rounded-full border border-solid px-2 py-1 text-sm font-medium leading-none"
|
||||
:class="memberStatusClasses"
|
||||
>
|
||||
{{ memberStatusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="truncate text-sm text-secondary">{{ memberSubtitle }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.whatHappensLabel)
|
||||
}}</span>
|
||||
<ul class="m-0 list-disc pl-6 text-primary">
|
||||
<li
|
||||
v-for="effect in effectMessages"
|
||||
:key="effect.id"
|
||||
class="leading-6 marker:text-secondary"
|
||||
>
|
||||
{{ formatMessage(effect) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-1">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-5" @click="hide">
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button v-tooltip="removePermissionTooltip" :disabled="!canRemove" @click="confirm">
|
||||
<TrashIcon v-if="modalState.shouldCancel" aria-hidden="true" />
|
||||
<UserXIcon v-else aria-hidden="true" />
|
||||
{{
|
||||
formatMessage(modalState.shouldCancel ? messages.cancelButton : messages.removeButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon, UserXIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useRelativeTime } from '../../../composables'
|
||||
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
||||
import { commonMessages } from '../../../utils/common-messages'
|
||||
import Admonition from '../../base/Admonition.vue'
|
||||
import Avatar from '../../base/Avatar.vue'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import NewModal from '../../modal/NewModal.vue'
|
||||
import type { ServerAccessRole } from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
username: string
|
||||
avatarUrl?: string
|
||||
role?: ServerAccessRole
|
||||
joinedAt?: string | null
|
||||
pending?: boolean
|
||||
shouldCancel?: boolean
|
||||
canRemove?: boolean
|
||||
permissionDeniedMessage?: string
|
||||
}>(),
|
||||
{
|
||||
avatarUrl: undefined,
|
||||
role: undefined,
|
||||
joinedAt: null,
|
||||
pending: false,
|
||||
shouldCancel: false,
|
||||
canRemove: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const cachedState = ref({
|
||||
username: '',
|
||||
avatarUrl: undefined as string | undefined,
|
||||
role: undefined as ServerAccessRole | undefined,
|
||||
joinedAt: null as string | null,
|
||||
pending: false,
|
||||
shouldCancel: false,
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'servers.remove-access-modal.header',
|
||||
defaultMessage: 'Remove user',
|
||||
},
|
||||
cancelHeader: {
|
||||
id: 'servers.remove-access-modal.cancel-header',
|
||||
defaultMessage: 'Cancel invite',
|
||||
},
|
||||
warningBody: {
|
||||
id: 'servers.remove-access-modal.warning-body',
|
||||
defaultMessage:
|
||||
"If you remove a user from your server, you'll need to re-invite them to restore access.",
|
||||
},
|
||||
cancelWarningBody: {
|
||||
id: 'servers.remove-access-modal.cancel-warning-body',
|
||||
defaultMessage:
|
||||
'If you cancel this invite, {username} will need a new invitation before they can join this server.',
|
||||
},
|
||||
removeButton: {
|
||||
id: 'servers.remove-access-modal.remove-button',
|
||||
defaultMessage: 'Remove user',
|
||||
},
|
||||
cancelButton: {
|
||||
id: 'servers.remove-access-modal.cancel-button',
|
||||
defaultMessage: 'Cancel invite',
|
||||
},
|
||||
userAvatarAlt: {
|
||||
id: 'servers.remove-access-modal.user-avatar-alt',
|
||||
defaultMessage: "{username}'s avatar",
|
||||
},
|
||||
whatHappensLabel: {
|
||||
id: 'servers.remove-access-modal.what-happens-label',
|
||||
defaultMessage: 'What happens?',
|
||||
},
|
||||
removeEffectAccess: {
|
||||
id: 'servers.remove-access-modal.remove-effect-access',
|
||||
defaultMessage:
|
||||
'They will immediately lose access to the server panel and will no longer be able to edit content',
|
||||
},
|
||||
removeEffectJoin: {
|
||||
id: 'servers.remove-access-modal.remove-effect-join',
|
||||
defaultMessage:
|
||||
'They will still be able to join and play on the server unless you make separate changes',
|
||||
},
|
||||
cancelEffectAccess: {
|
||||
id: 'servers.remove-access-modal.cancel-effect-access',
|
||||
defaultMessage: 'They will not be added to this server',
|
||||
},
|
||||
cancelEffectInvite: {
|
||||
id: 'servers.remove-access-modal.cancel-effect-invite',
|
||||
defaultMessage: 'You can send them another invite later',
|
||||
},
|
||||
addedLabel: {
|
||||
id: 'servers.remove-access-modal.added-label',
|
||||
defaultMessage: 'Added {time}',
|
||||
},
|
||||
invitedLabel: {
|
||||
id: 'servers.remove-access-modal.invited-label',
|
||||
defaultMessage: 'Invited {time}',
|
||||
},
|
||||
pendingInviteLabel: {
|
||||
id: 'servers.remove-access-modal.pending-invite-label',
|
||||
defaultMessage: 'Pending invite',
|
||||
},
|
||||
unknownAddedLabel: {
|
||||
id: 'servers.remove-access-modal.unknown-added-label',
|
||||
defaultMessage: 'Added date unknown',
|
||||
},
|
||||
ownerRole: {
|
||||
id: 'servers.access-role.owner',
|
||||
defaultMessage: 'Owner',
|
||||
},
|
||||
editorRole: {
|
||||
id: 'servers.access-role.editor',
|
||||
defaultMessage: 'Editor',
|
||||
},
|
||||
viewerRole: {
|
||||
id: 'servers.access-role.viewer',
|
||||
defaultMessage: 'Limited',
|
||||
},
|
||||
})
|
||||
|
||||
const modalState = computed(() => (props.username ? currentState() : cachedState.value))
|
||||
|
||||
watch(
|
||||
() =>
|
||||
[
|
||||
props.username,
|
||||
props.avatarUrl,
|
||||
props.role,
|
||||
props.joinedAt,
|
||||
props.pending,
|
||||
props.shouldCancel,
|
||||
] as const,
|
||||
() => {
|
||||
if (props.username) cachedState.value = currentState()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const memberStatusLabel = computed(() => {
|
||||
if (!modalState.value.role) return null
|
||||
return formatRole(modalState.value.role)
|
||||
})
|
||||
|
||||
const memberStatusClasses = computed(() => {
|
||||
if (!modalState.value.role) return ''
|
||||
return roleClasses(modalState.value.role)
|
||||
})
|
||||
|
||||
const memberSubtitle = computed(() => {
|
||||
if (modalState.value.shouldCancel || modalState.value.pending) {
|
||||
return modalState.value.joinedAt
|
||||
? formatMessage(messages.invitedLabel, {
|
||||
time: formatRelativeTime(modalState.value.joinedAt),
|
||||
})
|
||||
: formatMessage(messages.pendingInviteLabel)
|
||||
}
|
||||
|
||||
return modalState.value.joinedAt
|
||||
? formatMessage(messages.addedLabel, { time: formatRelativeTime(modalState.value.joinedAt) })
|
||||
: formatMessage(messages.unknownAddedLabel)
|
||||
})
|
||||
|
||||
const effectMessages = computed(() =>
|
||||
modalState.value.shouldCancel
|
||||
? [messages.cancelEffectAccess, messages.cancelEffectInvite]
|
||||
: [messages.removeEffectAccess, messages.removeEffectJoin],
|
||||
)
|
||||
const canRemove = computed(() => props.canRemove)
|
||||
const permissionDeniedMessage = computed(
|
||||
() => props.permissionDeniedMessage ?? formatMessage(commonMessages.noPermissionAction),
|
||||
)
|
||||
const removePermissionTooltip = computed(() =>
|
||||
canRemove.value ? undefined : permissionDeniedMessage.value,
|
||||
)
|
||||
|
||||
function currentState() {
|
||||
return {
|
||||
username: props.username,
|
||||
avatarUrl: props.avatarUrl,
|
||||
role: props.role,
|
||||
joinedAt: props.joinedAt,
|
||||
pending: props.pending,
|
||||
shouldCancel: props.shouldCancel,
|
||||
}
|
||||
}
|
||||
|
||||
function formatRole(role: ServerAccessRole): string {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return formatMessage(messages.ownerRole)
|
||||
case 'editor':
|
||||
return formatMessage(messages.editorRole)
|
||||
case 'viewer':
|
||||
return formatMessage(messages.viewerRole)
|
||||
}
|
||||
}
|
||||
|
||||
function roleClasses(role: ServerAccessRole): string {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return 'border-orange bg-highlight-orange text-orange'
|
||||
case 'editor':
|
||||
return 'border-green bg-highlight-green text-brand'
|
||||
case 'viewer':
|
||||
return 'border-blue bg-highlight-blue text-blue'
|
||||
}
|
||||
}
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (!canRemove.value) return
|
||||
hide()
|
||||
emit('remove')
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<BaseEvent>
|
||||
<span
|
||||
v-if="isDeleted"
|
||||
class="inline-flex min-w-0 max-w-full flex-wrap items-center gap-1 whitespace-normal align-middle @[800px]:flex-nowrap @[800px]:whitespace-nowrap"
|
||||
>
|
||||
<span class="shrink-0">{{ formatMessage(messages.deletedLabel) }}</span>
|
||||
<EventEntityList
|
||||
class="min-w-0"
|
||||
:entities="addonEntities"
|
||||
:limit="1"
|
||||
single-line
|
||||
entity-text-weight="semibold"
|
||||
/>
|
||||
</span>
|
||||
<IntlFormatted v-else :message-id="message">
|
||||
<template #content>
|
||||
<EventEntityList
|
||||
:entities="addonEntities"
|
||||
:single-line="true"
|
||||
:limit="contentLimit"
|
||||
entity-text-weight="semibold"
|
||||
/>
|
||||
</template>
|
||||
<template #files>
|
||||
<EventEntityList :entities="fileNames ?? []" :single-line="true" :limit="1" />
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</BaseEvent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { defineMessages, type MessageDescriptor, useVIntl } from '../../../../composables/i18n'
|
||||
import IntlFormatted from '../../../base/IntlFormatted.vue'
|
||||
import BaseEvent from './BaseEvent.vue'
|
||||
import EventEntityList from './EventEntityList.vue'
|
||||
import type { AuditAddonEventItem, EventEntity } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
kind: string
|
||||
addons?: AuditAddonEventItem[]
|
||||
fileNames?: EventEntity[]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
added: {
|
||||
id: 'servers.audit-log.event.addon-added',
|
||||
defaultMessage: 'Added content <content></content>',
|
||||
},
|
||||
uploaded: {
|
||||
id: 'servers.audit-log.event.addon-uploaded',
|
||||
defaultMessage: 'Uploaded <files></files>',
|
||||
},
|
||||
disabled: {
|
||||
id: 'servers.audit-log.event.addon-disabled',
|
||||
defaultMessage: 'Disabled content <content></content>',
|
||||
},
|
||||
enabled: {
|
||||
id: 'servers.audit-log.event.addon-enabled',
|
||||
defaultMessage: 'Enabled content <content></content>',
|
||||
},
|
||||
deleted: {
|
||||
id: 'servers.audit-log.event.addon-deleted',
|
||||
defaultMessage: 'Deleted content <content></content>',
|
||||
},
|
||||
deletedLabel: {
|
||||
id: 'servers.audit-log.event.addon-deleted-label',
|
||||
defaultMessage: 'Deleted content',
|
||||
},
|
||||
updated: {
|
||||
id: 'servers.audit-log.event.addon-updated',
|
||||
defaultMessage: 'Updated content <content></content>',
|
||||
},
|
||||
changed: {
|
||||
id: 'servers.audit-log.event.addon-changed',
|
||||
defaultMessage: 'Changed content <content></content>',
|
||||
},
|
||||
})
|
||||
|
||||
const kindMessages: Record<string, MessageDescriptor> = {
|
||||
added: messages.added,
|
||||
uploaded: messages.uploaded,
|
||||
disabled: messages.disabled,
|
||||
enabled: messages.enabled,
|
||||
deleted: messages.deleted,
|
||||
updated: messages.updated,
|
||||
}
|
||||
|
||||
const message = computed(() => kindMessages[props.kind] ?? messages.changed)
|
||||
const addonEntities = computed(() => props.addons?.map((addon) => addon.project) ?? [])
|
||||
const isDeleted = computed(() => props.kind === 'deleted')
|
||||
const shouldShowSingleItem = computed(() =>
|
||||
['added', 'disabled', 'enabled', 'updated'].includes(props.kind),
|
||||
)
|
||||
const contentLimit = computed(() => (shouldShowSingleItem.value ? 1 : undefined))
|
||||
</script>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<BaseEvent>
|
||||
<IntlFormatted :message-id="message">
|
||||
<template #backup>
|
||||
<EventEntityLink v-if="backup" :entity="backup" />
|
||||
</template>
|
||||
<template #renamed-backup>
|
||||
<EventEntityLink v-if="renamedBackup" :entity="renamedBackup" />
|
||||
</template>
|
||||
<template #from>
|
||||
<EventInlineText :text="from ?? ''" class="align-middle font-medium text-contrast" />
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</BaseEvent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { defineMessages, type MessageDescriptor } from '../../../../composables/i18n'
|
||||
import IntlFormatted from '../../../base/IntlFormatted.vue'
|
||||
import BaseEvent from './BaseEvent.vue'
|
||||
import EventEntityLink from './EventEntityLink.vue'
|
||||
import EventInlineText from './EventInlineText.vue'
|
||||
import type { AuditBackupEventItem } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
kind: string
|
||||
backup?: AuditBackupEventItem
|
||||
backupId?: string
|
||||
from?: string
|
||||
to?: string
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
created: {
|
||||
id: 'servers.audit-log.event.backup-created',
|
||||
defaultMessage: 'Created backup <backup></backup>',
|
||||
},
|
||||
restored: {
|
||||
id: 'servers.audit-log.event.backup-restored',
|
||||
defaultMessage: 'Restored backup <backup></backup>',
|
||||
},
|
||||
renamed: {
|
||||
id: 'servers.audit-log.event.backup-renamed',
|
||||
defaultMessage: 'Renamed backup <from></from> to <renamed-backup></renamed-backup>',
|
||||
},
|
||||
deleted: {
|
||||
id: 'servers.audit-log.event.backup-deleted',
|
||||
defaultMessage: 'Deleted backup <backup></backup>',
|
||||
},
|
||||
changed: {
|
||||
id: 'servers.audit-log.event.backup-changed',
|
||||
defaultMessage: 'Changed backup <backup></backup>',
|
||||
},
|
||||
createdFallback: {
|
||||
id: 'servers.audit-log.event.backup-created-fallback',
|
||||
defaultMessage: 'Created backup',
|
||||
},
|
||||
restoredFallback: {
|
||||
id: 'servers.audit-log.event.backup-restored-fallback',
|
||||
defaultMessage: 'Restored backup',
|
||||
},
|
||||
renamedFallback: {
|
||||
id: 'servers.audit-log.event.backup-renamed-fallback',
|
||||
defaultMessage: 'Renamed backup',
|
||||
},
|
||||
deletedFallback: {
|
||||
id: 'servers.audit-log.event.backup-deleted-fallback',
|
||||
defaultMessage: 'Deleted backup',
|
||||
},
|
||||
changedFallback: {
|
||||
id: 'servers.audit-log.event.backup-changed-fallback',
|
||||
defaultMessage: 'Changed backup',
|
||||
},
|
||||
})
|
||||
|
||||
const kindMessages: Record<string, MessageDescriptor> = {
|
||||
created: messages.created,
|
||||
restored: messages.restored,
|
||||
renamed: messages.renamed,
|
||||
deleted: messages.deleted,
|
||||
}
|
||||
|
||||
const fallbackMessages: Record<string, MessageDescriptor> = {
|
||||
created: messages.createdFallback,
|
||||
restored: messages.restoredFallback,
|
||||
renamed: messages.renamedFallback,
|
||||
deleted: messages.deletedFallback,
|
||||
}
|
||||
|
||||
const message = computed(() =>
|
||||
props.backup
|
||||
? (kindMessages[props.kind] ?? messages.changed)
|
||||
: (fallbackMessages[props.kind] ?? messages.changedFallback),
|
||||
)
|
||||
const renamedBackup = computed(() =>
|
||||
props.backup
|
||||
? {
|
||||
...props.backup,
|
||||
label: props.to ?? props.backup.label,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="min-w-0 text-primary">
|
||||
<span
|
||||
class="inline-flex max-w-full min-w-0 flex-wrap items-center gap-x-1 whitespace-normal align-middle leading-7 @[800px]:flex-nowrap @[800px]:whitespace-nowrap"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<BaseEvent>
|
||||
{{ formatMessage(message) }}
|
||||
</BaseEvent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { defineMessages, type MessageDescriptor, useVIntl } from '../../../../composables/i18n'
|
||||
import BaseEvent from './BaseEvent.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
action: string
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
serverCreated: {
|
||||
id: 'servers.audit-log.event.server-created',
|
||||
defaultMessage: 'Created server',
|
||||
},
|
||||
serverReallocated: {
|
||||
id: 'servers.audit-log.event.server-reallocated',
|
||||
defaultMessage: 'Reallocated server',
|
||||
},
|
||||
serverRepaired: {
|
||||
id: 'servers.audit-log.event.server-repaired',
|
||||
defaultMessage: 'Repaired server',
|
||||
},
|
||||
serverReset: {
|
||||
id: 'servers.audit-log.event.server-reset',
|
||||
defaultMessage: 'Reset server',
|
||||
},
|
||||
serverStarted: {
|
||||
id: 'servers.audit-log.event.server-started',
|
||||
defaultMessage: 'Started server',
|
||||
},
|
||||
serverStopped: {
|
||||
id: 'servers.audit-log.event.server-stopped',
|
||||
defaultMessage: 'Stopped server',
|
||||
},
|
||||
serverRestarted: {
|
||||
id: 'servers.audit-log.event.server-restarted',
|
||||
defaultMessage: 'Restarted server',
|
||||
},
|
||||
serverKilled: {
|
||||
id: 'servers.audit-log.event.server-killed',
|
||||
defaultMessage: 'Killed server',
|
||||
},
|
||||
sftpLogin: {
|
||||
id: 'servers.audit-log.event.sftp-login',
|
||||
defaultMessage: 'Logged in via SFTP',
|
||||
},
|
||||
consoleCleared: {
|
||||
id: 'servers.audit-log.event.console-cleared',
|
||||
defaultMessage: 'Cleared console',
|
||||
},
|
||||
unknown: {
|
||||
id: 'servers.audit-log.event.unknown-basic',
|
||||
defaultMessage: 'Recorded server activity',
|
||||
},
|
||||
})
|
||||
|
||||
const actionMessages: Record<string, MessageDescriptor> = {
|
||||
server_created: messages.serverCreated,
|
||||
server_reallocated: messages.serverReallocated,
|
||||
server_repaired: messages.serverRepaired,
|
||||
server_reset: messages.serverReset,
|
||||
server_started: messages.serverStarted,
|
||||
server_stopped: messages.serverStopped,
|
||||
server_restarted: messages.serverRestarted,
|
||||
server_killed: messages.serverKilled,
|
||||
sftp_login: messages.sftpLogin,
|
||||
console_cleared: messages.consoleCleared,
|
||||
}
|
||||
|
||||
const message = computed(() => actionMessages[props.action] ?? messages.unknown)
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<BaseEvent>
|
||||
<span
|
||||
v-if="props.kind === 'properties'"
|
||||
class="inline-flex max-w-full min-w-0 flex-wrap items-center gap-1 whitespace-normal align-middle @[800px]:flex-nowrap @[800px]:whitespace-nowrap"
|
||||
>
|
||||
<span class="shrink-0">{{ formatMessage(messages.propertiesModifiedLabel) }}</span>
|
||||
<EventInlineText
|
||||
:text="propertiesLabel"
|
||||
class="align-middle font-mono text-[0.925em] font-medium text-contrast"
|
||||
/>
|
||||
</span>
|
||||
<IntlFormatted v-else :message-id="message">
|
||||
<template #version>
|
||||
<EventInlineText :text="newVersion ?? ''" class="align-middle font-medium text-contrast" />
|
||||
</template>
|
||||
<template #loader>
|
||||
<EventInlineText :text="newLoaderLabel" class="align-middle font-medium text-contrast" />
|
||||
</template>
|
||||
<template #command>
|
||||
<EventInlineText
|
||||
:text="command ?? ''"
|
||||
class="align-middle font-mono font-medium text-contrast"
|
||||
/>
|
||||
</template>
|
||||
<template #vendor>
|
||||
<EventInlineText :text="vendor ?? ''" class="align-middle font-medium text-contrast" />
|
||||
</template>
|
||||
<template #java-version>
|
||||
<EventInlineText :text="version ?? ''" class="align-middle font-medium text-contrast" />
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</BaseEvent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { defineMessages, type MessageDescriptor, useVIntl } from '../../../../composables/i18n'
|
||||
import IntlFormatted from '../../../base/IntlFormatted.vue'
|
||||
import BaseEvent from './BaseEvent.vue'
|
||||
import EventInlineText from './EventInlineText.vue'
|
||||
import type { EventEntity } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
kind:
|
||||
| 'loader_version'
|
||||
| 'game_version'
|
||||
| 'properties'
|
||||
| 'startup_command'
|
||||
| 'java_runtime'
|
||||
| 'java_version'
|
||||
newVersion?: string | null
|
||||
newLoader?: string | null
|
||||
properties?: EventEntity[]
|
||||
command?: string
|
||||
vendor?: string
|
||||
version?: number
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
loaderVersionChanged: {
|
||||
id: 'servers.audit-log.event.loader-version-changed',
|
||||
defaultMessage: 'Changed loader version to <version></version>',
|
||||
},
|
||||
loaderChanged: {
|
||||
id: 'servers.audit-log.event.loader-changed',
|
||||
defaultMessage: 'Changed loader to <loader></loader>',
|
||||
},
|
||||
loaderAndVersionChanged: {
|
||||
id: 'servers.audit-log.event.loader-and-version-changed',
|
||||
defaultMessage: 'Changed loader to <loader></loader> <version></version>',
|
||||
},
|
||||
loaderVersionCleared: {
|
||||
id: 'servers.audit-log.event.loader-version-cleared',
|
||||
defaultMessage: 'Cleared loader version',
|
||||
},
|
||||
gameVersionChanged: {
|
||||
id: 'servers.audit-log.event.game-version-changed',
|
||||
defaultMessage: 'Changed Minecraft version to <version></version>',
|
||||
},
|
||||
propertiesModified: {
|
||||
id: 'servers.audit-log.event.server-properties-modified',
|
||||
defaultMessage: 'Modified server properties <properties></properties>',
|
||||
},
|
||||
propertiesModifiedLabel: {
|
||||
id: 'servers.audit-log.event.server-properties-modified-label',
|
||||
defaultMessage: 'Modified server properties',
|
||||
},
|
||||
startupCommandModified: {
|
||||
id: 'servers.audit-log.event.startup-command-modified',
|
||||
defaultMessage: 'Changed startup command to <command></command>',
|
||||
},
|
||||
javaRuntimeModified: {
|
||||
id: 'servers.audit-log.event.java-runtime-modified',
|
||||
defaultMessage: 'Changed Java runtime to <vendor></vendor>',
|
||||
},
|
||||
javaVersionModified: {
|
||||
id: 'servers.audit-log.event.java-version-modified',
|
||||
defaultMessage: 'Changed Java version to <java-version></java-version>',
|
||||
},
|
||||
configChanged: {
|
||||
id: 'servers.audit-log.event.config-changed',
|
||||
defaultMessage: 'Changed server configuration',
|
||||
},
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const propertiesLabel = computed(
|
||||
() => props.properties?.map((property) => property.label).join(', ') ?? '',
|
||||
)
|
||||
const newLoader = computed(() =>
|
||||
props.kind === 'loader_version' && props.newLoader == null ? 'vanilla' : props.newLoader,
|
||||
)
|
||||
const newLoaderLabel = computed(() => formatLoader(newLoader.value))
|
||||
|
||||
const kindMessages: Record<string, MessageDescriptor> = {
|
||||
game_version: messages.gameVersionChanged,
|
||||
properties: messages.propertiesModified,
|
||||
startup_command: messages.startupCommandModified,
|
||||
java_runtime: messages.javaRuntimeModified,
|
||||
java_version: messages.javaVersionModified,
|
||||
}
|
||||
|
||||
const message = computed(() => {
|
||||
if (props.kind === 'loader_version') {
|
||||
if (newLoader.value && props.newVersion) return messages.loaderAndVersionChanged
|
||||
if (newLoader.value) return messages.loaderChanged
|
||||
return props.newVersion == null ? messages.loaderVersionCleared : messages.loaderVersionChanged
|
||||
}
|
||||
return kindMessages[props.kind] ?? messages.configChanged
|
||||
})
|
||||
|
||||
function formatLoader(loader: string | null | undefined): string {
|
||||
if (!loader) return ''
|
||||
return loader
|
||||
.split(/[-_]/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<BaseEvent>
|
||||
<IntlFormatted :message-id="messages.commandExecuted">
|
||||
<template #command>
|
||||
<EventInlineText :text="command" class="align-middle font-mono font-medium text-contrast" />
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</BaseEvent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineMessages } from '../../../../composables/i18n'
|
||||
import IntlFormatted from '../../../base/IntlFormatted.vue'
|
||||
import BaseEvent from './BaseEvent.vue'
|
||||
import EventInlineText from './EventInlineText.vue'
|
||||
|
||||
defineProps<{
|
||||
command: string
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
commandExecuted: {
|
||||
id: 'servers.audit-log.event.console-command-executed',
|
||||
defaultMessage: 'Ran console command <command></command>',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<AutoLink
|
||||
:to="entity.to"
|
||||
class="inline-flex min-w-0 max-w-full flex-wrap items-center gap-0 @[800px]:flex-nowrap"
|
||||
:class="[
|
||||
stackSecondary ? '!grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-2 gap-y-0.5' : '',
|
||||
entity.to
|
||||
? `${textWeightClass} text-contrast hover:underline`
|
||||
: entity.muted
|
||||
? 'text-secondary'
|
||||
: `${textWeightClass} text-contrast`,
|
||||
entity.mono ? 'font-mono text-[0.925em]' : '',
|
||||
'align-middle',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
v-if="showAvatar || entity.icon"
|
||||
class="inline-flex shrink-0 items-center justify-center"
|
||||
:class="[
|
||||
stackSecondary ? 'row-span-2 self-center' : 'mr-1',
|
||||
entity.icon
|
||||
? 'size-7 rounded-lg border border-solid border-surface-5 bg-surface-4 text-secondary'
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<Avatar
|
||||
v-if="showAvatar"
|
||||
:src="entity.iconUrl"
|
||||
:alt="entity.label"
|
||||
size="1.75rem"
|
||||
no-shadow
|
||||
raised
|
||||
:tint-by="entity.label || entity.id"
|
||||
:circle="entity.iconShape === 'circle'"
|
||||
class="inline-flex shrink-0 border border-solid border-surface-5"
|
||||
:class="entity.iconShape === 'circle' ? '!rounded-full' : '!rounded-lg'"
|
||||
/>
|
||||
<component :is="entity.icon" v-else class="size-4" />
|
||||
</span>
|
||||
<span
|
||||
ref="labelRef"
|
||||
v-tooltip="truncatedTooltip(labelRef, entity.title ?? entity.label)"
|
||||
class="min-w-0 whitespace-normal break-words leading-7 @[800px]:truncate @[800px]:whitespace-nowrap"
|
||||
:class="stackSecondary ? 'leading-6 @[800px]:whitespace-normal @[800px]:break-words' : ''"
|
||||
>
|
||||
{{ entity.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="entity.secondaryLabel"
|
||||
ref="secondaryLabelRef"
|
||||
v-tooltip="truncatedTooltip(secondaryLabelRef, entity.secondaryLabel)"
|
||||
class="min-w-0 whitespace-normal break-words text-secondary @[800px]:truncate @[800px]:whitespace-nowrap"
|
||||
:class="
|
||||
stackSecondary
|
||||
? `col-start-2 leading-5 @[800px]:whitespace-normal @[800px]:break-words ${textWeightClass}`
|
||||
: `entity-secondary-label ${textWeightClass}`
|
||||
"
|
||||
>
|
||||
<template v-if="stackSecondary">{{ entity.secondaryLabel }}</template>
|
||||
<template v-else> {{ entity.secondaryLabel }}</template>
|
||||
</span>
|
||||
</AutoLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import AutoLink from '#ui/components/base/AutoLink.vue'
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import { truncatedTooltip } from '#ui/utils/truncate'
|
||||
|
||||
import type { EventEntity } from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
entity: EventEntity
|
||||
stackSecondary?: boolean
|
||||
textWeight?: 'medium' | 'semibold'
|
||||
}>(),
|
||||
{
|
||||
stackSecondary: false,
|
||||
textWeight: 'medium',
|
||||
},
|
||||
)
|
||||
|
||||
const textWeightClass = computed(() =>
|
||||
props.textWeight === 'semibold' ? 'font-semibold' : 'font-medium',
|
||||
)
|
||||
const showAvatar = computed(
|
||||
() => props.entity.iconUrl != null || props.entity.iconShape === 'circle',
|
||||
)
|
||||
const labelRef = ref<HTMLElement | null>(null)
|
||||
const secondaryLabelRef = ref<HTMLElement | null>(null)
|
||||
</script>
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex max-w-full min-w-0 items-center gap-x-1 align-middle"
|
||||
:class="
|
||||
singleLine
|
||||
? 'flex-wrap gap-y-0.5 whitespace-normal @[800px]:flex-nowrap @[800px]:overflow-hidden @[800px]:whitespace-nowrap'
|
||||
: 'flex-wrap gap-y-0.5'
|
||||
"
|
||||
>
|
||||
<template v-for="(entity, index) in visibleEntities" :key="entity.id">
|
||||
<EventEntityLink
|
||||
:entity="entity"
|
||||
:text-weight="entityTextWeight"
|
||||
:class="singleLine ? 'min-w-0 shrink' : ''"
|
||||
/>
|
||||
<span v-if="index < visibleEntities.length - 1" class="shrink-0 text-secondary">,</span>
|
||||
</template>
|
||||
<Tooltip
|
||||
v-if="hiddenCount > 0"
|
||||
theme="dismissable-prompt"
|
||||
class="inline-flex shrink-0 items-center"
|
||||
:triggers="['hover', 'focus']"
|
||||
:popper-triggers="['hover', 'focus']"
|
||||
popper-class="v-popper--interactive audit-log-entity-list-popper"
|
||||
placement="top"
|
||||
:delay="{ show: 200, hide: 100 }"
|
||||
no-auto-focus
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-w-0 cursor-help items-center rounded-full border border-solid border-surface-5 bg-surface-4 px-1.5 py-1 leading-none text-xs text-secondary"
|
||||
:aria-label="hiddenTooltip"
|
||||
>
|
||||
{{ formatMessage(messages.hiddenCount, { count: hiddenCount }) }}
|
||||
</button>
|
||||
<template #popper>
|
||||
<div class="relative max-w-[22rem]">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-3"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-3"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showTopFade"
|
||||
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-3 bg-gradient-to-b from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
<div
|
||||
ref="hiddenEntitiesScrollContainer"
|
||||
class="flex flex-col gap-2 overflow-y-auto overscroll-contain py-0.5"
|
||||
:style="{ maxHeight: hiddenEntitiesMaxHeight }"
|
||||
@scroll="checkScrollState"
|
||||
>
|
||||
<EventEntityLink
|
||||
v-for="entity in hiddenEntities"
|
||||
:key="entity.id"
|
||||
:entity="entity"
|
||||
:text-weight="entityTextWeight"
|
||||
class="min-w-0 pr-4"
|
||||
stack-secondary
|
||||
/>
|
||||
</div>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-3"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-3"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showBottomFade"
|
||||
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-3 bg-gradient-to-t from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from 'floating-vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../../../composables/i18n'
|
||||
import { useScrollIndicator } from '../../../../composables/scroll-indicator'
|
||||
import EventEntityLink from './EventEntityLink.vue'
|
||||
import type { EventEntity } from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
entities: EventEntity[]
|
||||
limit?: number
|
||||
singleLine?: boolean
|
||||
entityTextWeight?: 'medium' | 'semibold'
|
||||
}>(),
|
||||
{
|
||||
limit: 3,
|
||||
singleLine: true,
|
||||
entityTextWeight: 'medium',
|
||||
},
|
||||
)
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
const hiddenEntitiesScrollContainer = ref<HTMLElement | null>(null)
|
||||
const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(
|
||||
hiddenEntitiesScrollContainer,
|
||||
)
|
||||
|
||||
const TOOLTIP_VISIBLE_ROWS = 8
|
||||
const TOOLTIP_ENTITY_HEIGHT_REM = 1.75
|
||||
const TOOLTIP_ENTITY_GAP_REM = 0.5
|
||||
const hiddenEntitiesMaxHeight = `${
|
||||
TOOLTIP_VISIBLE_ROWS * TOOLTIP_ENTITY_HEIGHT_REM +
|
||||
(TOOLTIP_VISIBLE_ROWS - 1) * TOOLTIP_ENTITY_GAP_REM
|
||||
}rem`
|
||||
|
||||
const messages = defineMessages({
|
||||
hiddenCount: {
|
||||
id: 'servers.audit-log.event.entity-list.hidden-count',
|
||||
defaultMessage: '+{count, number}',
|
||||
},
|
||||
})
|
||||
|
||||
const visibleEntities = computed(() => props.entities.slice(0, props.limit))
|
||||
const hiddenEntities = computed(() => props.entities.slice(props.limit))
|
||||
const hiddenCount = computed(() => hiddenEntities.value.length)
|
||||
const hiddenTooltip = computed(() => {
|
||||
void locale.value
|
||||
return new Intl.ListFormat(locale.value, {
|
||||
style: 'long',
|
||||
type: 'conjunction',
|
||||
}).format(hiddenEntities.value.map((entity) => entity.label))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.v-popper__popper.v-popper--theme-dismissable-prompt.audit-log-entity-list-popper {
|
||||
.v-popper__inner {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<span
|
||||
ref="textRef"
|
||||
v-tooltip="truncatedTooltip(textRef, tooltipText)"
|
||||
class="max-w-full min-w-0 whitespace-normal break-words @[800px]:truncate @[800px]:whitespace-nowrap"
|
||||
>
|
||||
<slot>{{ text }}</slot>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { truncatedTooltip } from '#ui/utils/truncate'
|
||||
|
||||
const props = defineProps<{
|
||||
text: string | number
|
||||
tooltip?: string
|
||||
}>()
|
||||
|
||||
const textRef = ref<HTMLElement | null>(null)
|
||||
const tooltipText = computed(() => props.tooltip ?? String(props.text))
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<BaseEvent>
|
||||
<IntlFormatted :message-id="message">
|
||||
<template #file>
|
||||
<EventEntityLink v-if="file" :entity="file" />
|
||||
</template>
|
||||
<template #from>
|
||||
<EventEntityLink v-if="from" :entity="from" />
|
||||
</template>
|
||||
<template #to>
|
||||
<EventEntityLink v-if="to" :entity="to" />
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</BaseEvent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { defineMessages, type MessageDescriptor } from '../../../../composables/i18n'
|
||||
import IntlFormatted from '../../../base/IntlFormatted.vue'
|
||||
import BaseEvent from './BaseEvent.vue'
|
||||
import EventEntityLink from './EventEntityLink.vue'
|
||||
import type { EventEntity } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
kind: string
|
||||
file?: EventEntity
|
||||
from?: EventEntity
|
||||
to?: EventEntity
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
uploaded: {
|
||||
id: 'servers.audit-log.event.file-uploaded',
|
||||
defaultMessage: 'Uploaded file <file></file>',
|
||||
},
|
||||
deleted: {
|
||||
id: 'servers.audit-log.event.file-deleted',
|
||||
defaultMessage: 'Deleted file <file></file>',
|
||||
},
|
||||
edited: {
|
||||
id: 'servers.audit-log.event.file-edited',
|
||||
defaultMessage: 'Edited file <file></file>',
|
||||
},
|
||||
renamed: {
|
||||
id: 'servers.audit-log.event.file-renamed',
|
||||
defaultMessage: 'Renamed <from></from> to <to></to>',
|
||||
},
|
||||
changed: {
|
||||
id: 'servers.audit-log.event.file-changed',
|
||||
defaultMessage: 'Changed file <file></file>',
|
||||
},
|
||||
})
|
||||
|
||||
const kindMessages: Record<string, MessageDescriptor> = {
|
||||
uploaded: messages.uploaded,
|
||||
deleted: messages.deleted,
|
||||
edited: messages.edited,
|
||||
renamed: messages.renamed,
|
||||
}
|
||||
|
||||
const message = computed(() => kindMessages[props.kind] ?? messages.changed)
|
||||
</script>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<BaseEvent>
|
||||
<IntlFormatted :message-id="message">
|
||||
<template #modpack>
|
||||
<EventEntityLink v-if="modpack" :entity="modpack" />
|
||||
</template>
|
||||
<template #version>
|
||||
<EventInlineText :text="versionLabel" class="align-middle font-mono text-secondary" />
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</BaseEvent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { defineMessages } from '../../../../composables/i18n'
|
||||
import IntlFormatted from '../../../base/IntlFormatted.vue'
|
||||
import BaseEvent from './BaseEvent.vue'
|
||||
import EventEntityLink from './EventEntityLink.vue'
|
||||
import EventInlineText from './EventInlineText.vue'
|
||||
import type { EventEntity } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
kind: 'changed' | 'unlinked'
|
||||
modpack?: EventEntity | null
|
||||
versionLabel?: string | null
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
changed: {
|
||||
id: 'servers.audit-log.event.modpack-changed',
|
||||
defaultMessage: 'Changed modpack',
|
||||
},
|
||||
changedToModpack: {
|
||||
id: 'servers.audit-log.event.modpack-changed-to-modpack',
|
||||
defaultMessage: 'Changed modpack to <modpack></modpack>',
|
||||
},
|
||||
changedToVersion: {
|
||||
id: 'servers.audit-log.event.modpack-changed-to-version',
|
||||
defaultMessage: 'Changed modpack to version <version></version>',
|
||||
},
|
||||
unlinked: {
|
||||
id: 'servers.audit-log.event.modpack-unlinked',
|
||||
defaultMessage: 'Unlinked modpack',
|
||||
},
|
||||
unlinkedModpack: {
|
||||
id: 'servers.audit-log.event.modpack-unlinked-modpack',
|
||||
defaultMessage: 'Unlinked modpack <modpack></modpack>',
|
||||
},
|
||||
unlinkedVersion: {
|
||||
id: 'servers.audit-log.event.modpack-unlinked-version',
|
||||
defaultMessage: 'Unlinked modpack version <version></version>',
|
||||
},
|
||||
})
|
||||
|
||||
const message = computed(() => {
|
||||
if (props.kind === 'unlinked') {
|
||||
if (props.modpack) return messages.unlinkedModpack
|
||||
return props.versionLabel ? messages.unlinkedVersion : messages.unlinked
|
||||
}
|
||||
|
||||
if (props.modpack) return messages.changedToModpack
|
||||
return props.versionLabel ? messages.changedToVersion : messages.changed
|
||||
})
|
||||
|
||||
const versionLabel = computed(() => props.versionLabel ?? '')
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<BaseEvent>
|
||||
<IntlFormatted :message-id="message">
|
||||
<template #port>
|
||||
<span class="font-mono font-medium text-contrast">{{ port }}</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</BaseEvent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { defineMessages } from '../../../../composables/i18n'
|
||||
import IntlFormatted from '../../../base/IntlFormatted.vue'
|
||||
import BaseEvent from './BaseEvent.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
kind: 'added' | 'removed'
|
||||
port: number
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
added: {
|
||||
id: 'servers.audit-log.event.port-allocation-added',
|
||||
defaultMessage: 'Added port allocation <port></port>',
|
||||
},
|
||||
removed: {
|
||||
id: 'servers.audit-log.event.port-allocation-removed',
|
||||
defaultMessage: 'Removed port allocation <port></port>',
|
||||
},
|
||||
})
|
||||
|
||||
const message = computed(() => (props.kind === 'added' ? messages.added : messages.removed))
|
||||
</script>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<BaseEvent>
|
||||
<IntlFormatted :message-id="message">
|
||||
<template #name>
|
||||
<EventEntityLink :entity="nameEntity" />
|
||||
</template>
|
||||
<template #subdomain>
|
||||
<EventEntityLink :entity="subdomainEntity" />
|
||||
</template>
|
||||
<template #specs>
|
||||
<EventInlineText :text="specsLabel" class="align-middle font-medium text-contrast" />
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</BaseEvent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { defineMessages, type MessageDescriptor, useVIntl } from '../../../../composables/i18n'
|
||||
import IntlFormatted from '../../../base/IntlFormatted.vue'
|
||||
import BaseEvent from './BaseEvent.vue'
|
||||
import EventEntityLink from './EventEntityLink.vue'
|
||||
import EventInlineText from './EventInlineText.vue'
|
||||
import type { EventEntity } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
kind: 'name' | 'subdomain' | 'plan'
|
||||
name?: string
|
||||
subdomain?: string
|
||||
newSpecs?: Record<string, unknown>
|
||||
}>()
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
nameChanged: {
|
||||
id: 'servers.audit-log.event.server-name-changed',
|
||||
defaultMessage: 'Changed server name to <name></name>',
|
||||
},
|
||||
subdomainChanged: {
|
||||
id: 'servers.audit-log.event.server-subdomain-changed',
|
||||
defaultMessage: 'Changed server subdomain to <subdomain></subdomain>',
|
||||
},
|
||||
planChanged: {
|
||||
id: 'servers.audit-log.event.server-plan-changed',
|
||||
defaultMessage: 'Changed plan to <specs></specs>',
|
||||
},
|
||||
changed: {
|
||||
id: 'servers.audit-log.event.server-metadata-changed',
|
||||
defaultMessage: 'Changed server metadata',
|
||||
},
|
||||
cpuSpec: {
|
||||
id: 'servers.audit-log.event.server-plan.cpu',
|
||||
defaultMessage: '{count, plural, one {# CPU} other {# CPUs}}',
|
||||
},
|
||||
ramGb: {
|
||||
id: 'servers.audit-log.event.server-plan.ram-gb',
|
||||
defaultMessage: '{amount, number} GB RAM',
|
||||
},
|
||||
ramMb: {
|
||||
id: 'servers.audit-log.event.server-plan.ram-mb',
|
||||
defaultMessage: '{amount, number} MB RAM',
|
||||
},
|
||||
storageGb: {
|
||||
id: 'servers.audit-log.event.server-plan.storage-gb',
|
||||
defaultMessage: '{amount, number} GB storage',
|
||||
},
|
||||
storageMb: {
|
||||
id: 'servers.audit-log.event.server-plan.storage-mb',
|
||||
defaultMessage: '{amount, number} MB storage',
|
||||
},
|
||||
newPlan: {
|
||||
id: 'servers.audit-log.event.server-plan.new-plan',
|
||||
defaultMessage: 'new plan',
|
||||
},
|
||||
})
|
||||
|
||||
const kindMessages: Record<string, MessageDescriptor> = {
|
||||
name: messages.nameChanged,
|
||||
subdomain: messages.subdomainChanged,
|
||||
plan: messages.planChanged,
|
||||
}
|
||||
|
||||
const message = computed(() => kindMessages[props.kind] ?? messages.changed)
|
||||
const nameEntity = computed<EventEntity>(() => ({
|
||||
id: props.name ?? '',
|
||||
label: props.name ?? '',
|
||||
}))
|
||||
const subdomainEntity = computed<EventEntity>(() => ({
|
||||
id: props.subdomain ?? '',
|
||||
label: props.subdomain ?? '',
|
||||
mono: true,
|
||||
}))
|
||||
|
||||
const specsLabel = computed(() => {
|
||||
void locale.value
|
||||
|
||||
const cpu = numberValue(props.newSpecs?.cpu)
|
||||
const memory = numberValue(props.newSpecs?.memory_mb)
|
||||
const storage = numberValue(props.newSpecs?.storage_mb)
|
||||
const parts = []
|
||||
if (cpu != null) parts.push(formatMessage(messages.cpuSpec, { count: cpu }))
|
||||
if (memory != null) parts.push(formatMemoryMb(memory))
|
||||
if (storage != null) parts.push(formatStorageMb(storage))
|
||||
return parts.length > 0 ? parts.join(' / ') : formatMessage(messages.newPlan)
|
||||
})
|
||||
|
||||
function numberValue(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
function formatMemoryMb(value: number): string {
|
||||
if (value >= 1024) return formatMessage(messages.ramGb, { amount: Math.round(value / 1024) })
|
||||
return formatMessage(messages.ramMb, { amount: value })
|
||||
}
|
||||
|
||||
function formatStorageMb(value: number): string {
|
||||
if (value >= 1024) {
|
||||
return formatMessage(messages.storageGb, { amount: Math.round(value / 1024) })
|
||||
}
|
||||
return formatMessage(messages.storageMb, { amount: value })
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<BaseEvent>
|
||||
<span class="mr-1 text-secondary">{{ formatMessage(messages.unknownEvent) }}</span>
|
||||
<EventInlineText :text="rawAction" class="font-mono text-secondary" />
|
||||
</BaseEvent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '../../../../composables/i18n'
|
||||
import BaseEvent from './BaseEvent.vue'
|
||||
import EventInlineText from './EventInlineText.vue'
|
||||
|
||||
defineProps<{
|
||||
rawAction: string
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
unknownEvent: {
|
||||
id: 'servers.audit-log.event.unknown',
|
||||
defaultMessage: 'Unknown event',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<BaseEvent>
|
||||
<IntlFormatted :message-id="message" :values="{ permissions: permissionLabel }">
|
||||
<template #target-user>
|
||||
<EventEntityLink :entity="targetUser" />
|
||||
</template>
|
||||
<template #permission-label="{ children }">
|
||||
<span
|
||||
v-if="permissionRole"
|
||||
class="inline-flex h-7 items-center rounded-full border border-solid px-2.5 py-1 text-sm font-semibold leading-none align-middle"
|
||||
:class="roleClasses(permissionRole)"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</span>
|
||||
<component :is="() => children" v-else />
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</BaseEvent>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { defineMessages, type MessageDescriptor, useVIntl } from '../../../../composables/i18n'
|
||||
import IntlFormatted from '../../../base/IntlFormatted.vue'
|
||||
import { apiPermissionsToAccessRole } from '../permissions'
|
||||
import type { ServerAccessRole } from '../types'
|
||||
import BaseEvent from './BaseEvent.vue'
|
||||
import EventEntityLink from './EventEntityLink.vue'
|
||||
import type { EventEntity } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
kind: 'invited' | 'invite_revoked' | 'permission_modified' | 'removed'
|
||||
targetUser: EventEntity
|
||||
permissions?: Archon.ServerUsers.v1.UserScope | null
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
invited: {
|
||||
id: 'servers.audit-log.event.user-invited',
|
||||
defaultMessage: 'Invited <target-user></target-user>',
|
||||
},
|
||||
invitedWithPermissions: {
|
||||
id: 'servers.audit-log.event.user-invited-with-permissions',
|
||||
defaultMessage:
|
||||
'Invited <target-user></target-user> with <permission-label>{permissions}</permission-label>',
|
||||
},
|
||||
inviteRevoked: {
|
||||
id: 'servers.audit-log.event.user-invite-revoked',
|
||||
defaultMessage: 'Revoked invite for <target-user></target-user>',
|
||||
},
|
||||
permissionModified: {
|
||||
id: 'servers.audit-log.event.user-permission-modified',
|
||||
defaultMessage: 'Changed permissions for <target-user></target-user>',
|
||||
},
|
||||
permissionModifiedWithPermissions: {
|
||||
id: 'servers.audit-log.event.user-permission-modified-with-permissions',
|
||||
defaultMessage:
|
||||
'Changed permissions for <target-user></target-user> to <permission-label>{permissions}</permission-label>',
|
||||
},
|
||||
removed: {
|
||||
id: 'servers.audit-log.event.user-removed',
|
||||
defaultMessage: 'Removed <target-user></target-user>',
|
||||
},
|
||||
ownerRole: {
|
||||
id: 'servers.access-role.owner',
|
||||
defaultMessage: 'Owner',
|
||||
},
|
||||
editorRole: {
|
||||
id: 'servers.access-role.editor',
|
||||
defaultMessage: 'Editor',
|
||||
},
|
||||
viewerRole: {
|
||||
id: 'servers.access-role.viewer',
|
||||
defaultMessage: 'Limited',
|
||||
},
|
||||
})
|
||||
|
||||
const roleMessages: Record<ServerAccessRole, MessageDescriptor> = {
|
||||
owner: messages.ownerRole,
|
||||
editor: messages.editorRole,
|
||||
viewer: messages.viewerRole,
|
||||
}
|
||||
|
||||
const message = computed(() => {
|
||||
if (props.kind === 'invited') {
|
||||
return permissionLabel.value ? messages.invitedWithPermissions : messages.invited
|
||||
}
|
||||
if (props.kind === 'invite_revoked') return messages.inviteRevoked
|
||||
if (props.kind === 'permission_modified') {
|
||||
return permissionLabel.value
|
||||
? messages.permissionModifiedWithPermissions
|
||||
: messages.permissionModified
|
||||
}
|
||||
return messages.removed
|
||||
})
|
||||
|
||||
const permissionLabel = computed(() => {
|
||||
const role = permissionRole.value
|
||||
if (role) return formatMessage(roleMessages[role])
|
||||
return ''
|
||||
})
|
||||
|
||||
const permissionRole = computed(() =>
|
||||
props.permissions == null || props.permissions === ''
|
||||
? null
|
||||
: apiPermissionsToAccessRole(props.permissions),
|
||||
)
|
||||
|
||||
function roleClasses(role: ServerAccessRole): string {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return 'border-orange bg-highlight-orange text-orange'
|
||||
case 'editor':
|
||||
return 'border-green bg-highlight-green text-brand'
|
||||
case 'viewer':
|
||||
return 'border-blue bg-highlight-blue text-blue'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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'
|
||||
@@ -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<string, unknown>,
|
||||
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<string, Archon.Actions.v1.UserResp>,
|
||||
): 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<string, unknown> | 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<string, AuditWorld>,
|
||||
): AuditWorld | null {
|
||||
if (!worldId) return null
|
||||
return worldById.get(worldId) ?? { id: worldId, name: worldId }
|
||||
}
|
||||
|
||||
function userEntity(
|
||||
userId: string,
|
||||
users: Record<string, Archon.Actions.v1.UserResp>,
|
||||
): 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<string, unknown> | 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<string, Archon.Actions.v1.AddonResp>,
|
||||
versions: Record<string, Archon.Actions.v1.VersionResp>,
|
||||
): 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, Archon.Actions.v1.VersionResp>,
|
||||
): 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<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function objectField(
|
||||
record: Record<string, unknown> | null,
|
||||
key: string,
|
||||
): Record<string, unknown> | null {
|
||||
return metadataRecord(record?.[key])
|
||||
}
|
||||
|
||||
function arrayField(record: Record<string, unknown> | null, key: string): unknown[] | null {
|
||||
const value = record?.[key]
|
||||
return Array.isArray(value) ? value : null
|
||||
}
|
||||
|
||||
function stringArrayField(record: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | null, key: string): string | null {
|
||||
const value = record?.[key]
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function numberField(record: Record<string, unknown> | 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)
|
||||
}
|
||||
@@ -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<string, string | undefined>
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
searchText: string
|
||||
}
|
||||
|
||||
export interface AuditEventLookups {
|
||||
serverId: string
|
||||
users: Record<string, Archon.Actions.v1.UserResp>
|
||||
addons: Record<string, Archon.Actions.v1.AddonResp>
|
||||
versions: Record<string, Archon.Actions.v1.VersionResp>
|
||||
worldById: Map<string, AuditWorld>
|
||||
backupById: Map<string, Archon.Backups.v1.Backup>
|
||||
}
|
||||
@@ -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'
|
||||
@@ -0,0 +1,23 @@
|
||||
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')) {
|
||||
return 'owner'
|
||||
}
|
||||
if (
|
||||
hasServerPermission(permissions, 'EXEC_COMMANDS') ||
|
||||
hasServerPermission(permissions, 'FILES_WRITE') ||
|
||||
hasServerPermission(permissions, 'SETUP') ||
|
||||
hasServerPermission(permissions, 'BACKUPS') ||
|
||||
hasServerPermission(permissions, 'ADVANCED') ||
|
||||
hasServerPermission(permissions, 'RESET_SERVER')
|
||||
) {
|
||||
return 'editor'
|
||||
}
|
||||
return 'viewer'
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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
|
||||
user: ServerAccessInviteSuggestion
|
||||
role: Exclude<ServerAccessRole, 'owner'>
|
||||
addAsFriend: boolean
|
||||
}
|
||||
|
||||
export interface ServerListingOwner {
|
||||
username: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
@@ -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 {
|
||||
</div>
|
||||
<template #top-right-actions>
|
||||
<ButtonStyled v-if="canCancel(item)" type="outlined" color="blue">
|
||||
<button class="!border" type="button" :disabled="cancelling" @click="$emit('cancel')">
|
||||
<button
|
||||
v-tooltip="canManageBackups === false ? permissionDeniedMessage : undefined"
|
||||
class="!border"
|
||||
type="button"
|
||||
:disabled="cancelling || canManageBackups === false"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="canRetry(item)" color="red" type="outlined">
|
||||
<button class="!border" type="button" @click="$emit('retry')">
|
||||
<button
|
||||
v-tooltip="canManageBackups === false ? permissionDeniedMessage : undefined"
|
||||
class="!border"
|
||||
type="button"
|
||||
:disabled="canManageBackups === false"
|
||||
@click="$emit('retry')"
|
||||
>
|
||||
<RotateCounterClockwiseIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.retryButton) }}
|
||||
</button>
|
||||
|
||||
@@ -25,7 +25,13 @@
|
||||
</span>
|
||||
<template v-if="op.id" #top-right-actions>
|
||||
<ButtonStyled v-if="!isTerminal" type="outlined" color="blue">
|
||||
<button class="!border" type="button" @click="ctx.dismissOperation(op.id!, 'cancel')">
|
||||
<button
|
||||
v-tooltip="!canWriteFiles ? permissionDeniedMessage : undefined"
|
||||
class="!border"
|
||||
type="button"
|
||||
:disabled="!canWriteFiles"
|
||||
@click="cancelOperation"
|
||||
>
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -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')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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 {
|
||||
@@ -337,6 +340,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()
|
||||
@@ -402,6 +406,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')"
|
||||
/>
|
||||
@@ -422,6 +428,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)"
|
||||
|
||||
@@ -53,7 +53,11 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="createMutation.isPending.value || nameExists" @click="createBackup">
|
||||
<button
|
||||
v-tooltip="createDisabledTooltip"
|
||||
:disabled="createDisabled"
|
||||
@click="createBackup"
|
||||
>
|
||||
<PlusIcon />
|
||||
Create backup
|
||||
</button>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -67,7 +67,11 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="confirmDelete">
|
||||
<button
|
||||
v-tooltip="deleteDisabledTooltip"
|
||||
:disabled="!props.canDelete"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.confirm, { count }) }}
|
||||
</button>
|
||||
@@ -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)
|
||||
|
||||
@@ -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<OverflowOption[]>(() => {
|
||||
const options: OverflowOption[] = []
|
||||
|
||||
@@ -81,13 +93,20 @@ const overflowMenuOptions = computed<OverflowOption[]>(() => {
|
||||
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({
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-4 rounded-[20px] border border-solid bg-surface-3 p-4 shadow-[0px_1px_2px_0px_rgba(0,0,0,0.3),0px_1px_3px_0px_rgba(0,0,0,0.15)]"
|
||||
:class="props.selected ? 'border-brand-green' : 'border-transparent'"
|
||||
:class="itemBorderClass"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-4">
|
||||
<!-- Icon tile -->
|
||||
@@ -215,3 +234,25 @@ const messages = defineMessages({
|
||||
}}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes backup-item-highlight-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 0 var(--color-purple-highlight),
|
||||
0px 1px 2px 0px rgba(0, 0, 0, 0.3),
|
||||
0px 1px 3px 0px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 0 0 3px var(--color-purple-highlight),
|
||||
0px 1px 2px 0px rgba(0, 0, 0, 0.3),
|
||||
0px 1px 3px 0px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.backup-item-highlighted {
|
||||
animation: backup-item-highlight-pulse 1.25s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,7 +29,11 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="renameMutation.isPending.value || nameExists" @click="renameBackup">
|
||||
<button
|
||||
v-tooltip="renameDisabledTooltip"
|
||||
:disabled="renameDisabled"
|
||||
@click="renameBackup"
|
||||
>
|
||||
<template v-if="renameMutation.isPending.value">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
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',
|
||||
|
||||
@@ -24,7 +24,11 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="isRestoring || ctx.isServerRunning.value" @click="restoreBackup">
|
||||
<button
|
||||
v-tooltip="restoreDisabledTooltip"
|
||||
:disabled="restoreDisabled"
|
||||
@click="restoreBackup"
|
||||
>
|
||||
<SpinnerIcon v-if="isRestoring" class="animate-spin" />
|
||||
<RotateCounterClockwiseIcon v-else />
|
||||
{{ 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<InstanceType<typeof NewModal>>()
|
||||
const currentBackup = ref<Archon.BackupsQueue.v1.BackupQueueBackup | null>(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',
|
||||
|
||||
@@ -3,17 +3,21 @@
|
||||
<span class="text-lg font-semibold text-contrast">Icon</span>
|
||||
<div class="group relative w-fit">
|
||||
<OverflowMenu
|
||||
v-tooltip="'Edit icon'"
|
||||
v-tooltip="editIconTooltip"
|
||||
class="m-0 cursor-pointer appearance-none border-none bg-transparent p-0 transition-transform group-active:scale-95"
|
||||
:disabled="isIconActionLoading"
|
||||
:disabled="isIconActionDisabled"
|
||||
:options="[
|
||||
{
|
||||
id: 'upload',
|
||||
action: () => triggerFileInput(),
|
||||
disabled: !props.canEdit,
|
||||
tooltip: !props.canEdit ? editIconTooltip : undefined,
|
||||
},
|
||||
{
|
||||
id: 'sync',
|
||||
action: () => resetIcon(),
|
||||
disabled: !props.canEdit,
|
||||
tooltip: !props.canEdit ? editIconTooltip : undefined,
|
||||
},
|
||||
]"
|
||||
>
|
||||
@@ -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'
|
||||
|
||||
@@ -224,9 +224,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LoaderIcon } from '@modrinth/assets'
|
||||
import type { Loaders } from '@modrinth/utils'
|
||||
|
||||
import type { ServerLoader } from '#ui/utils/loaders'
|
||||
|
||||
defineProps<{
|
||||
loader: Loaders
|
||||
loader: ServerLoader
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './access'
|
||||
export * from './admonitions'
|
||||
export * from './backups'
|
||||
export * from './flows'
|
||||
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
@@ -36,13 +36,29 @@
|
||||
</div>
|
||||
<Avatar v-else src="https://cdn-raw.modrinth.com/medal_icon.webp" size="64px" class="z-10" />
|
||||
<div class="z-10 ml-4 flex min-w-0 flex-col gap-1.5">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="flex flex-row items-center gap-2.5">
|
||||
<h2
|
||||
class="m-0 truncate text-xl font-bold text-contrast"
|
||||
:class="{ 'opacity-50': isDisabled }"
|
||||
>
|
||||
{{ name }}
|
||||
</h2>
|
||||
<div
|
||||
v-if="owner"
|
||||
v-tooltip="formatMessage(messages.ownerTooltip, { username: owner.username })"
|
||||
class="flex min-w-0 items-center gap-1.5 rounded-full bg-surface-4/80 px-2 py-1 text-sm font-medium text-primary !border !border-surface-5 border-solid"
|
||||
:class="{ 'opacity-50': isDisabled }"
|
||||
>
|
||||
<Avatar
|
||||
:src="owner.avatarUrl"
|
||||
:alt="formatMessage(messages.ownerAvatarAlt, { username: owner.username })"
|
||||
:tint-by="owner.username"
|
||||
size="1.25rem"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<span class="max-w-32 truncate">{{ owner.username }}</span>
|
||||
</div>
|
||||
|
||||
<span class="truncate" :class="{ 'opacity-50': isDisabled }">
|
||||
<IntlFormatted
|
||||
@@ -158,6 +174,7 @@ import Avatar from '../../base/Avatar.vue'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import CopyCode from '../../base/CopyCode.vue'
|
||||
import IntlFormatted from '../../base/IntlFormatted.vue'
|
||||
import type { ServerListingOwner } from '../access/types'
|
||||
import ServerInfoLabels from '../labels/ServerInfoLabels.vue'
|
||||
import MedalBackgroundImage from './MedalBackgroundImage.vue'
|
||||
|
||||
@@ -176,6 +193,7 @@ type MedalServerListingProps = {
|
||||
upstream?: Archon.Servers.v0.Upstream | null
|
||||
flows?: Archon.Servers.v0.Flows
|
||||
medal_expires?: string
|
||||
owner?: ServerListingOwner
|
||||
}
|
||||
|
||||
const props = defineProps<MedalServerListingProps>()
|
||||
@@ -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',
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
:actions="stopSplitActions"
|
||||
:primary-disabled="!canTakeAction"
|
||||
:dropdown-disabled="!canKill"
|
||||
:primary-tooltip="busyTooltip"
|
||||
:dropdown-tooltip="busyTooltip"
|
||||
>
|
||||
<template #kill_server>
|
||||
<SlashIcon class="h-5 w-5" />
|
||||
@@ -37,6 +39,7 @@
|
||||
:primary-disabled="true"
|
||||
:dropdown-disabled="!canKill"
|
||||
:primary-muted="true"
|
||||
:dropdown-tooltip="busyTooltip"
|
||||
>
|
||||
<template #kill_server>
|
||||
<SlashIcon class="h-5 w-5" />
|
||||
|
||||
@@ -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<boolean> }) {
|
||||
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<boolean> }) {
|
||||
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(() => {
|
||||
|
||||
Reference in New Issue
Block a user