mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 17:44:50 +00:00
* 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>
626 lines
20 KiB
Vue
626 lines
20 KiB
Vue
<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>
|