New organizations (#1488)

* [WIP] Transfer organizations to own branch

* push progress

* Setup organizations page

* Add organizations grid to user profile

* Remove debug

* Add error handling for failed organization fetch

* Refactor organization page and settings

* Restructure to composition setup api

* checklist completion

* Apply suggestions from code review

Co-authored-by: Emma Alexia <emma@modrinth.com>

* Update pages/[type]/[id]/settings/index.vue

Co-authored-by: Emma Alexia <emma@modrinth.com>

* Update pages/[type]/[id]/settings/index.vue

Co-authored-by: Emma Alexia <emma@modrinth.com>

* Update pages/[type]/[id]/settings/index.vue

Co-authored-by: Emma Alexia <emma@modrinth.com>

* Update pages/[type]/[id]/settings/index.vue

Co-authored-by: Emma Alexia <emma@modrinth.com>

* Clean up org state management

* Refactor useClientTry to simplify code

* Remove unused code and update dependencies

* Refactor bulkEditLinks event handler

* Refactor organization management functions

* Update heading from "Creators" to "Members"

* Refactor team member invitation

* Refactor member management functions

* Implement validation on clientside for org names

* Name sanitization for fun characters

* Update onInviteTeamMember function parameters

* Remove name

* sidebar

* random rendering issue

* Conform to org removal

* Org no projects conditional

* Update organization links in dashboard

* Update Cards to universal-cards

* Refactor gallery upload permissions

* Refactor to sidebar pattern

* Update button classes in gallery and versions components

* Finish (most)

* almost finish

* Finish orgs :D

* Fix lint

* orgs fixes

* fix most things

* project settings

* convert grid to cards

* clean up unused test class

* Settings -> Manage

* add org view to org management

* Fix prop mounting issue

* fix analytics grid layout overflow

* fix multiselect breaking layout

* Refactor chart selection logic in ChartDisplay.vue

* Add transfer modal

---------

Co-authored-by: Jai A <jaiagr+gpg@pm.me>
Co-authored-by: Emma Alexia <emma@modrinth.com>
This commit is contained in:
Carter
2024-01-06 15:09:26 -08:00
committed by GitHub
parent 1108b0264e
commit d893765b24
44 changed files with 4092 additions and 1037 deletions

538
pages/organization/[id].vue Normal file
View File

@@ -0,0 +1,538 @@
<template>
<div v-if="organization" class="normal-page">
<div class="normal-page__sidebar">
<div v-if="routeHasSettings" class="universal-card">
<Breadcrumbs
current-title="Settings"
:link-stack="[
{ href: `/dashboard/organizations`, label: 'Organizations' },
{
href: `/organization/${organization.slug}`,
label: organization.name,
allowTrimming: true,
},
]"
/>
<div class="page-header__settings">
<Avatar size="sm" :src="organization.icon_url" />
<div class="title-section">
<h2 class="settings-title">
<nuxt-link :to="`/organization/${organization.slug}/settings`">
{{ organization.name }}
</nuxt-link>
</h2>
<span>
<span>
{{ $formatNumber(acceptedMembers?.length || 0) }}
</span>
member
<span v-if="acceptedMembers?.length !== 1">s</span>
</span>
</div>
</div>
<h2>Organization settings</h2>
<NavStack>
<NavStackItem :link="`/organization/${organization.slug}/settings`" label="Overview">
<SettingsIcon />
</NavStackItem>
<NavStackItem
:link="`/organization/${organization.slug}/settings/members`"
label="Members"
>
<UsersIcon />
</NavStackItem>
<NavStackItem
:link="`/organization/${organization.slug}/settings/projects`"
label="Projects"
>
<BoxIcon />
</NavStackItem>
<NavStackItem
:link="`/organization/${organization.slug}/settings/analytics`"
label="Analytics"
>
<ChartIcon />
</NavStackItem>
</NavStack>
</div>
<template v-else>
<div class="universal-card">
<div class="page-header__icon">
<Avatar size="md" :src="organization.icon_url" />
</div>
<div class="page-header__text">
<h1 class="title">{{ organization.name }}</h1>
<div>
<span class="organization-label"><OrganizationIcon /> Organization</span>
</div>
<div class="organization-description">
<div class="metadata-item markdown-body collection-description">
<p>{{ organization.description }}</p>
</div>
<hr class="card-divider" />
<div class="primary-stat">
<UserIcon class="primary-stat__icon" aria-hidden="true" />
<div class="primary-stat__text">
<span class="primary-stat__counter">
{{ $formatNumber(acceptedMembers?.length || 0) }}
</span>
member<span v-if="acceptedMembers?.length !== 1">s</span>
</div>
</div>
<div class="primary-stat no-margin">
<BoxIcon class="primary-stat__icon" aria-hidden="true" />
<div class="primary-stat__text">
<span class="primary-stat__counter">
{{ $formatNumber(projects?.length || 0) }}
</span>
project<span v-if="organization.projects?.length !== 1">s</span>
</div>
</div>
</div>
</div>
</div>
<div class="creator-list universal-card">
<div class="title-and-link">
<h3>Members</h3>
</div>
<template v-for="member in acceptedMembers" :key="member.user.id">
<nuxt-link class="creator button-base" :to="`/user/${member.user.username}`">
<Avatar :src="member.user.avatar_url" circle />
<p class="name">{{ member.user.username }}</p>
<p class="role">{{ member.role }}</p>
</nuxt-link>
</template>
</div>
</template>
</div>
<div v-if="!routeHasSettings" class="normal-page__content">
<ModalCreation ref="modal_creation" :organization-id="organization.id" />
<Promotion />
<div v-if="isInvited" class="universal-card information invited">
<h2>Invitation to join {{ organization.name }}</h2>
<p>You have been invited to join {{ organization.name }}.</p>
<div class="input-group">
<button class="iconified-button brand-button" @click="onAcceptInvite">
<CheckIcon />Accept
</button>
<button class="iconified-button danger-button" @click="onDeclineInvite">
<XIcon />Decline
</button>
</div>
</div>
<nav class="navigation-card">
<NavRow
:links="[
{
label: formatMessage(commonMessages.allProjectType),
href: `/organization/${organization.slug}`,
},
...projectTypes.map((x) => {
return {
label: formatMessage(getProjectTypeMessage(x, true)),
href: `/organization/${organization.slug}/${x}s`,
}
}),
]"
/>
<div v-if="auth.user && currentMember" class="input-group">
<nuxt-link :to="`/organization/${organization.slug}/settings`" class="iconified-button">
<SettingsIcon /> Manage
</nuxt-link>
</div>
</nav>
<template v-if="projects?.length > 0">
<div class="project-list display-mode--list">
<ProjectCard
v-for="project in route.params.projectType !== undefined
? projects.filter((x) =>
x.project_types.includes(
route.params.projectType.substr(0, route.params.projectType.length - 1)
)
)
: projects"
:id="project.slug || project.id"
:key="project.id"
:name="project.name"
:display="cosmetics.searchDisplayMode.user"
:featured-image="
project.gallery
.slice()
.sort((a, b) => b.featured - a.featured)
.map((x) => x.url)[0]
"
project-type-url="project"
:description="project.summary"
:created-at="project.published"
:updated-at="project.updated"
:downloads="project.downloads.toString()"
:follows="project.followers.toString()"
:icon-url="project.icon_url"
:categories="project.categories"
:status="
auth.user && (auth.user.id === user.id || tags.staffRoles.includes(auth.user.role))
? project.status
: null
"
:type="project.project_type"
:color="project.color"
/>
</div>
</template>
<div v-else-if="true" class="error">
<UpToDate class="icon" /><br />
<span class="preserve-lines text">
This organization doesn't have any projects yet.
<template v-if="isPermission(currentMember?.organization_permissions, 1 << 4)">
Would you like to
<a class="link" @click="$refs.modal_creation.show()">create one</a>?
</template>
</span>
</div>
</div>
<NuxtPage />
</div>
</template>
<script setup>
import {
Avatar,
BoxIcon,
Breadcrumbs,
UserIcon,
UsersIcon,
SettingsIcon,
ChartIcon,
Promotion,
CheckIcon,
XIcon,
} from 'omorphia'
import NavStack from '~/components/ui/NavStack.vue'
import NavStackItem from '~/components/ui/NavStackItem.vue'
import NavRow from '~/components/ui/NavRow.vue'
import ModalCreation from '~/components/ui/ModalCreation.vue'
import UpToDate from '~/assets/images/illustrations/up_to_date.svg'
import ProjectCard from '~/components/ui/ProjectCard.vue'
import OrganizationIcon from '~/assets/images/utils/organization.svg'
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
const vintl = useVIntl()
const { formatMessage } = vintl
const auth = await useAuth()
const user = await useUser()
const cosmetics = useCosmetics()
const route = useRoute()
const tags = useTags()
let orgId = useRouteId()
// hacky way to show the edit button on the corner of the card.
const routeHasSettings = computed(() => route.path.includes('settings'))
const [
{ data: organization, refresh: refreshOrganization },
{ data: projects, refresh: refreshProjects },
] = await Promise.all([
useAsyncData(`organization/${orgId}`, () =>
useBaseFetch(`organization/${orgId}`, { apiVersion: 3 })
),
useAsyncData(`organization/${orgId}/projects`, () =>
useBaseFetch(`organization/${orgId}/projects`, { apiVersion: 3 })
),
])
const refresh = async () => {
await Promise.all([refreshOrganization(), refreshProjects()])
}
if (!organization.value) {
throw createError({
fatal: true,
statusCode: 404,
message: 'Organization not found',
})
}
// Filter accepted, sort by role, then by name and Owner role always goes first
const acceptedMembers = computed(() => {
const acceptedMembers = organization.value.members?.filter((x) => x.accepted)
const owner = acceptedMembers.find((x) => x.role === 'Owner')
const rest = acceptedMembers.filter((x) => x.role !== 'Owner') || []
rest.sort((a, b) => {
if (a.role === b.role) {
return a.user.username.localeCompare(b.user.username)
} else {
return a.role.localeCompare(b.role)
}
})
return [owner, ...rest]
})
const currentMember = computed(() => {
if (auth.value.user && organization.value) {
const member = organization.value.members.find((x) => x.user.id === auth.value.user.id)
if (member) {
return member
}
if (tags.value.staffRoles.includes(auth.value.user.role)) {
return {
user: auth.value.user,
role: auth.value.user.role,
permissions: auth.value.user.role === 'admin' ? 1023 : 12,
accepted: true,
payouts_split: 0,
avatar_url: auth.value.user.avatar_url,
name: auth.value.user.username,
}
}
}
return null
})
const hasPermission = computed(() => {
const EDIT_DETAILS = 1 << 2
return currentMember.value && (currentMember.value.permissions & EDIT_DETAILS) === EDIT_DETAILS
})
const isInvited = computed(() => {
return currentMember.value?.accepted === false
})
const projectTypes = computed(() => {
const obj = {}
for (const project of projects.value) {
obj[project.project_types[0] ?? 'project'] = true
}
delete obj.project
return Object.keys(obj)
})
const patchIcon = async (icon) => {
const ext = icon.name.split('.').pop()
await useBaseFetch(`organization/${organization.value.id}/icon`, {
method: 'PATCH',
body: icon,
query: { ext },
apiVersion: 3,
})
}
const deleteIcon = async () => {
await useBaseFetch(`organization/${organization.value.id}/icon`, {
method: 'DELETE',
apiVersion: 3,
})
}
const patchOrganization = async (id, newData) => {
await useBaseFetch(`organization/${id}`, {
method: 'PATCH',
body: newData,
apiVersion: 3,
})
if (newData.slug) {
orgId = newData.slug
}
}
const onAcceptInvite = useClientTry(async () => {
await acceptTeamInvite(organization.value.team_id)
await refreshOrganization()
})
const onDeclineInvite = useClientTry(async () => {
await removeTeamMember(organization.value.team_id, auth.value?.user.id)
await refreshOrganization()
})
provide('organizationContext', {
organization,
projects,
refresh,
currentMember,
hasPermission,
patchIcon,
deleteIcon,
patchOrganization,
})
const title = `${organization.value.name} - Organization`
const description = `${organization.value.description} - View the organization ${organization.value.name} on Modrinth`
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: organization.value.description,
ogImage: organization.value.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
})
</script>
<style scoped lang="scss">
.page-header__settings {
display: flex;
flex-direction: row;
gap: var(--gap-md);
margin-bottom: var(--gap-md);
.title-section {
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--gap-xs);
}
.settings-title {
margin: 0 !important;
font-size: var(--font-size-md);
}
}
.page-header__icon {
margin-block: 0 !important;
}
.universal-card {
h1 {
margin-bottom: var(--gap-md);
}
}
.creator-list {
display: flex;
flex-direction: column;
padding: var(--gap-xl);
h3 {
margin: 0 0 var(--gap-sm);
}
.creator {
display: grid;
gap: var(--gap-xs);
background-color: var(--color-raised-bg);
padding: var(--gap-sm);
margin-left: -0.5rem;
border-radius: var(--radius-lg);
grid-template:
'avatar name' auto
'avatar role' auto
/ auto 1fr;
p {
margin: 0;
}
.name {
grid-area: name;
align-self: flex-end;
margin-left: var(--gap-xs);
font-weight: bold;
}
.role {
grid-area: role;
align-self: flex-start;
margin-left: var(--gap-xs);
}
.avatar {
grid-area: avatar;
}
}
}
.secondary-stat {
align-items: center;
display: flex;
margin-bottom: 0.8rem;
}
.secondary-stat__icon {
height: 1rem;
width: 1rem;
}
.secondary-stat__text {
margin-left: 0.4rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.title {
margin: var(--gap-md) 0 var(--spacing-card-xs) 0;
font-size: var(--font-size-xl);
color: var(--color-text-dark);
}
.organization-label {
font-weight: 500;
display: flex;
align-items: center;
gap: 0.25rem;
}
.organization-description {
margin-top: var(--spacing-card-sm);
margin-bottom: 0;
}
.title-and-link {
display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin: 0;
}
a {
display: flex;
align-items: center;
gap: var(--gap-xs);
color: var(--color-blue);
}
}
.project-overview {
gap: var(--gap-md);
padding: var(--gap-xl);
.project-card {
padding: 0;
border-radius: 0;
background-color: transparent;
box-shadow: none;
:deep(.title) {
font-size: var(--font-size-nm) !important;
}
}
}
.popout-heading {
padding: var(--gap-sm) var(--gap-md);
margin: 0;
font-size: var(--font-size-md);
color: var(--color-text);
}
.popout-checkbox {
padding: var(--gap-sm) var(--gap-md);
}
</style>

View File

@@ -0,0 +1 @@
<template><div /></template>

View File

@@ -0,0 +1,27 @@
<template>
<div>
<div class="universal-card">
<h2>Analytics</h2>
<p>
This page shows you the analytics for your organization's projects. You can see the number
of downloads, page views and revenue earned for all of your projects, as well as the total
downloads and page views for each project by country.
</p>
</div>
<ChartDisplay :projects="projects" />
</div>
</template>
<script setup>
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
const { projects } = inject('organizationContext')
</script>
<style scoped lang="scss">
.markdown-body {
margin-bottom: var(--gap-md);
}
</style>

View File

@@ -0,0 +1,218 @@
<script setup>
import { Button, FileInput, TrashIcon, Avatar, UploadIcon, SaveIcon, ConfirmModal } from 'omorphia'
const {
organization,
refresh: refreshOrganization,
hasPermission,
deleteIcon,
patchIcon,
patchOrganization,
} = inject('organizationContext')
const icon = ref(null)
const deletedIcon = ref(false)
const previewImage = ref(null)
const name = ref(organization.value.name)
const slug = ref(organization.value.slug)
const summary = ref(organization.value.description)
const patchData = computed(() => {
const data = {}
if (name.value !== organization.value.name) {
data.name = name.value
}
if (slug.value !== organization.value.slug) {
data.slug = slug.value
}
if (summary.value !== organization.value.description) {
data.description = summary.value
}
return data
})
const hasChanges = computed(() => {
return Object.keys(patchData.value).length > 0 || deletedIcon.value || icon.value
})
const markIconForDeletion = () => {
deletedIcon.value = true
icon.value = null
previewImage.value = null
}
const showPreviewImage = (files) => {
const reader = new FileReader()
icon.value = files[0]
deletedIcon.value = false
reader.readAsDataURL(icon.value)
reader.onload = (event) => {
previewImage.value = event.target.result
}
}
const orgId = useRouteId()
const onSaveChanges = useClientTry(async () => {
if (hasChanges.value) {
await patchOrganization(orgId, patchData.value)
}
if (deletedIcon.value) {
await deleteIcon()
deletedIcon.value = false
} else if (icon.value) {
await patchIcon(icon.value)
icon.value = null
}
await refreshOrganization()
addNotification({
group: 'main',
title: 'Organization updated',
text: 'Your organization has been updated.',
type: 'success',
})
})
const onDeleteOrganization = useClientTry(async () => {
await useBaseFetch(`organization/${orgId}`, {
method: 'DELETE',
apiVersion: 3,
})
addNotification({
group: 'main',
title: 'Organization deleted',
text: 'Your organization has been deleted.',
type: 'success',
})
await navigateTo('/dashboard/organizations')
})
</script>
<template>
<div class="normal-page__content">
<ConfirmModal
ref="modal_deletion"
:title="`Are you sure you want to delete ${organization.name}?`"
description="This will delete this organization forever (like *forever* ever)."
:has-to-type="true"
proceed-label="Delete"
:confirmation-text="organization.name"
@proceed="onDeleteOrganization"
/>
<div class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Organization information</span>
</h3>
</div>
<label for="project-icon">
<span class="label__title">Icon</span>
</label>
<div class="input-group">
<Avatar
:src="deletedIcon ? null : previewImage ? previewImage : organization.icon_url"
:alt="organization.name"
size="md"
class="project__icon"
/>
<div class="input-stack">
<FileInput
id="project-icon"
:max-size="262144"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="btn"
prompt="Upload icon"
:disabled="!hasPermission"
@change="showPreviewImage"
>
<UploadIcon />
</FileInput>
<Button
v-if="!deletedIcon && (previewImage || organization.icon_url)"
:disabled="!hasPermission"
@click="markIconForDeletion"
>
<TrashIcon />
Remove icon
</Button>
</div>
</div>
<label for="project-name">
<span class="label__title">Name</span>
</label>
<input
id="project-name"
v-model="name"
maxlength="2048"
type="text"
:disabled="!hasPermission"
/>
<label for="project-slug">
<span class="label__title">URL</span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/organization/</div>
<input
id="project-slug"
v-model="slug"
type="text"
maxlength="64"
autocomplete="off"
:disabled="!hasPermission"
/>
</div>
<label for="project-summary">
<span class="label__title">Summary</span>
</label>
<div class="textarea-wrapper summary-input">
<textarea
id="project-summary"
v-model="summary"
maxlength="256"
:disabled="!hasPermission"
/>
</div>
<div class="button-group">
<Button color="primary" :disabled="!hasChanges" @click="onSaveChanges">
<SaveIcon />
Save changes
</Button>
</div>
</div>
<div class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Delete organization</span>
</h3>
</div>
<p>
Deleting your organization will transfer all of its projects to the organization owner. This
action cannot be undone.
</p>
<Button color="danger" @click="() => $refs.modal_deletion.show()">
<TrashIcon />
Delete organization
</Button>
</div>
</div>
</template>
<style scoped lang="scss">
.summary-input {
min-height: 8rem;
max-width: 24rem;
}
</style>

View File

@@ -0,0 +1,428 @@
<template>
<div class="normal-page__content">
<div class="universal-card">
<div class="label">
<h3>
<span class="label__title size-card-header">Manage members</span>
</h3>
</div>
<span class="label">
<span class="label__title">Invite a member</span>
<span class="label__description">
Enter the Modrinth username of the person you'd like to invite to be a member of this
organization.
</span>
</span>
<div class="input-group">
<input
id="username"
v-model="currentUsername"
type="text"
placeholder="Username"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.MANAGE_INVITES
)
"
@keypress.enter="() => onInviteTeamMember(organization.team_id, currentUsername)"
/>
<label for="username" class="hidden">Username</label>
<Button
color="primary"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.MANAGE_INVITES
)
"
@click="() => onInviteTeamMember(organization.team_id, currentUsername)"
>
<UserPlusIcon />
Invite
</Button>
</div>
<div class="adjacent-input">
<span class="label">
<span class="label__title">Leave organization</span>
<span class="label__description">
Remove yourself as a member of this organization.
</span>
</span>
<Button color="danger" :disabled="currentMember.role === 'Owner'" @click="leaveProject()">
<UserRemoveIcon />
Leave organization
</Button>
</div>
</div>
<div
v-for="(member, index) in allTeamMembers"
:key="member.user.id"
class="member universal-card"
:class="{ open: openTeamMembers.includes(member.user.id) }"
>
<div class="member-header">
<div class="info">
<Avatar :src="member.user.avatar_url" :alt="member.user.username" size="sm" circle />
<div class="text">
<nuxt-link :to="'/user/' + member.user.username" class="name">
<p>{{ member.user.username }}</p>
</nuxt-link>
<p>{{ member.role }}</p>
</div>
</div>
<div class="side-buttons">
<Badge v-if="member.accepted" type="accepted" />
<Badge v-else type="pending" />
<Button
icon-only
transparent
class="dropdown-icon"
@click="
openTeamMembers.indexOf(member.user.id) === -1
? openTeamMembers.push(member.user.id)
: (openTeamMembers = openTeamMembers.filter((it) => it !== member.user.id))
"
>
<DropdownIcon />
</Button>
</div>
</div>
<div class="content">
<div v-if="member.oldRole !== 'Owner'" class="adjacent-input">
<label :for="`member-${member.user.id}-role`">
<span class="label__title">Role</span>
<span class="label__description">
The title of the role that this member plays for this organization.
</span>
</label>
<input
:id="`member-${member.user.id}-role`"
v-model="member.role"
type="text"
:class="{ 'known-error': member.role === 'Owner' }"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.EDIT_MEMBER
)
"
/>
</div>
<div class="adjacent-input">
<label :for="`member-${member.user.id}-monetization-weight`">
<span class="label__title">Monetization weight</span>
<span class="label__description">
Relative to all other members' monetization weights, this determines what portion of
the organization projects' revenue goes to this member.
</span>
</label>
<input
:id="`member-${member.user.id}-monetization-weight`"
v-model="member.payouts_split"
type="number"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.EDIT_MEMBER
)
"
/>
</div>
<p v-if="member.role === 'Owner' && member.oldRole !== 'Owner'" class="known-errors">
A organization can only have one 'Owner'. Use the 'Transfer ownership' button below if you
no longer wish to be owner.
</p>
<template v-if="member.oldRole !== 'Owner'">
<span class="label">
<span class="label__title">Project permissions</span>
</span>
<div class="permissions">
<Checkbox
v-for="[label, permission] in Object.entries(projectPermissions)"
:key="permission"
:model-value="isPermission(member.permissions, permission)"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.EDIT_MEMBER_DEFAULT_PERMISSIONS
) || !isPermission(currentMember.permissions, permission)
"
:label="permToLabel(label)"
@update:model-value="allTeamMembers[index].permissions ^= permission"
/>
</div>
</template>
<template v-if="member.oldRole !== 'Owner'">
<span class="label">
<span class="label__title">Organization permissions</span>
</span>
<div class="permissions">
<Checkbox
v-for="[label, permission] in Object.entries(organizationPermissions)"
:key="permission"
:model-value="isPermission(member.organization_permissions, permission)"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.EDIT_MEMBER
) || !isPermission(currentMember.organization_permissions, permission)
"
:label="permToLabel(label)"
@update:model-value="allTeamMembers[index].organization_permissions ^= permission"
/>
</div>
</template>
<div class="input-group">
<Button
color="primary"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.EDIT_MEMBER
)
"
@click="onUpdateTeamMember(organization.team_id, member)"
>
<SaveIcon />
Save changes
</Button>
<Button
v-if="member.oldRole !== 'Owner'"
color="danger"
:disabled="
!isPermission(
currentMember.organization_permissions,
organizationPermissions.EDIT_MEMBER
) &&
!isPermission(
currentMember.organization_permissions,
organizationPermissions.REMOVE_MEMBER
)
"
@click="onRemoveMember(organization.team_id, member)"
>
<UserRemoveIcon />
Remove member
</Button>
<Button
v-if="member.oldRole !== 'Owner' && currentMember.role === 'Owner' && member.accepted"
@click="() => onTransferOwnership(organization.team_id, member.user.id)"
>
<TransferIcon />
Transfer ownership
</Button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
Avatar,
Checkbox,
SaveIcon,
Badge,
TransferIcon,
UserPlusIcon,
UserXIcon as UserRemoveIcon,
DropdownIcon,
Button,
} from 'omorphia'
import { ref } from 'vue'
import { removeTeamMember } from '~/helpers/teams.js'
import { isPermission } from '~/utils/permissions.ts'
const { organization, refresh: refreshOrganization, currentMember } = inject('organizationContext')
const auth = await useAuth()
const currentUsername = ref('')
const openTeamMembers = ref([])
const processMembers = (members) => {
return members
.map((x) => ({ ...x, oldRole: x.role }))
.sort((a, b) => a.user.username.localeCompare(b.user.username))
}
const allTeamMembers = ref(processMembers(organization.value.members))
watch(
() => organization.value,
() => {
allTeamMembers.value = processMembers(organization.value.members)
},
{ deep: true, immediate: true }
)
const projectPermissions = {
UPLOAD_VERSION: 1 << 0,
DELETE_VERSION: 1 << 1,
EDIT_DETAILS: 1 << 2,
EDIT_BODY: 1 << 3,
MANAGE_INVITES: 1 << 4,
REMOVE_MEMBER: 1 << 5,
EDIT_MEMBER: 1 << 6,
DELETE_PROJECT: 1 << 7,
VIEW_ANALYTICS: 1 << 8,
VIEW_PAYOUTS: 1 << 9,
}
const organizationPermissions = {
EDIT_DETAILS: 1 << 0,
MANAGE_INVITES: 1 << 1,
REMOVE_MEMBER: 1 << 2,
EDIT_MEMBER: 1 << 3,
ADD_PROJECT: 1 << 4,
REMOVE_PROJECT: 1 << 5,
DELETE_ORGANIZATION: 1 << 6,
EDIT_MEMBER_DEFAULT_PERMISSIONS: 1 << 7,
}
const permToLabel = (key) => {
const o = key.split('_').join(' ')
return o.charAt(0).toUpperCase() + o.slice(1).toLowerCase()
}
const leaveProject = async () => {
await removeTeamMember(organization.value.team_id, auth.user.id)
await navigateTo(`/organization/${organization.value.slug}`)
}
const onInviteTeamMember = useClientTry(async (teamId, username) => {
const user = await useBaseFetch(`user/${username}`)
const data = {
user_id: user.id.trim(),
}
await useBaseFetch(`team/${teamId}/members`, {
method: 'POST',
body: data,
})
await refreshOrganization()
currentUsername.value = ''
addNotification({
group: 'main',
title: 'Member invited',
text: `${user.username} has been invited to the organization.`,
type: 'success',
})
})
const onRemoveMember = useClientTry(async (teamId, member) => {
await removeTeamMember(teamId, member.user.id)
await refreshOrganization()
addNotification({
group: 'main',
title: 'Member removed',
text: `${member.user.username} has been removed from the organization.`,
type: 'success',
})
})
const onUpdateTeamMember = useClientTry(async (teamId, member) => {
const data =
member.oldRole !== 'Owner'
? {
permissions: member.permissions,
organization_permissions: member.organization_permissions,
role: member.role,
payouts_split: member.payouts_split,
}
: {
payouts_split: member.payouts_split,
}
await useBaseFetch(`team/${teamId}/members/${member.user.id}`, {
method: 'PATCH',
body: data,
})
await refreshOrganization()
addNotification({
group: 'main',
title: 'Member updated',
text: `${member.user.username} has been updated.`,
type: 'success',
})
})
const onTransferOwnership = useClientTry(async (teamId, uid) => {
const data = {
user_id: uid,
}
await useBaseFetch(`team/${teamId}/owner`, {
method: 'PATCH',
body: data,
})
await refreshOrganization()
addNotification({
group: 'main',
title: 'Ownership transferred',
text: `The ownership of ${organization.value.name} has been successfully transferred.`,
type: 'success',
})
})
</script>
<style lang="scss" scoped>
.member {
.member-header {
display: flex;
justify-content: space-between;
.info {
display: flex;
.text {
margin: auto 0 auto 0.5rem;
font-size: var(--font-size-sm);
.name {
font-weight: bold;
}
p {
margin: 0.2rem 0;
}
}
}
.side-buttons {
display: flex;
align-items: center;
.dropdown-icon {
margin-left: 1rem;
svg {
transition: 150ms ease transform;
}
}
}
}
.content {
display: none;
flex-direction: column;
padding-top: var(--gap-md);
.main-info {
margin-bottom: var(--gap-lg);
}
.permissions {
margin-bottom: var(--gap-md);
max-width: 45rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-gap: 0.5rem;
}
}
&.open {
.member-header {
.dropdown-icon svg {
transform: rotate(180deg);
}
}
.content {
display: flex;
}
}
}
:deep(.checkbox-outer) {
button.checkbox {
border: none;
}
}
</style>

View File

@@ -0,0 +1,668 @@
<template>
<div class="normal-page__content">
<Modal ref="editLinksModal" header="Edit links">
<div class="universal-modal links-modal">
<p>
Any links you specify below will be overwritten on each of the selected projects. Any you
leave blank will be ignored. You can clear a link from all selected projects using the
trash can button.
</p>
<section class="links">
<label
for="issue-tracker-input"
title="A place for users to report bugs, issues, and concerns about your project."
>
<span class="label__title">Issue tracker</span>
</label>
<div class="input-group shrink-first">
<input
id="issue-tracker-input"
v-model="editLinks.issues.val"
:disabled="editLinks.issues.clear"
type="url"
:placeholder="
editLinks.issues.clear ? 'Existing link will be cleared' : 'Enter a valid URL'
"
maxlength="2048"
/>
<Button
v-tooltip="'Clear link'"
aria-label="Clear link"
class="square-button label-button"
:data-active="editLinks.issues.clear"
icon-only
@click="editLinks.issues.clear = !editLinks.issues.clear"
>
<TrashIcon />
</Button>
</div>
<label
for="source-code-input"
title="A page/repository containing the source code for your project"
>
<span class="label__title">Source code</span>
</label>
<div class="input-group shrink-first">
<input
id="source-code-input"
v-model="editLinks.source.val"
:disabled="editLinks.source.clear"
type="url"
maxlength="2048"
:placeholder="
editLinks.source.clear ? 'Existing link will be cleared' : 'Enter a valid URL'
"
/>
<Button
v-tooltip="'Clear link'"
aria-label="Clear link"
:data-active="editLinks.source.clear"
icon-only
@click="editLinks.source.clear = !editLinks.source.clear"
>
<TrashIcon />
</Button>
</div>
<label
for="wiki-page-input"
title="A page containing information, documentation, and help for the project."
>
<span class="label__title">Wiki page</span>
</label>
<div class="input-group shrink-first">
<input
id="wiki-page-input"
v-model="editLinks.wiki.val"
:disabled="editLinks.wiki.clear"
type="url"
maxlength="2048"
:placeholder="
editLinks.wiki.clear ? 'Existing link will be cleared' : 'Enter a valid URL'
"
/>
<Button
v-tooltip="'Clear link'"
aria-label="Clear link"
:data-active="editLinks.wiki.clear"
icon-only
@click="editLinks.wiki.clear = !editLinks.wiki.clear"
>
<TrashIcon />
</Button>
</div>
<label for="discord-invite-input" title="An invitation link to your Discord server.">
<span class="label__title">Discord invite</span>
</label>
<div class="input-group shrink-first">
<input
id="discord-invite-input"
v-model="editLinks.discord.val"
:disabled="editLinks.discord.clear"
type="url"
maxlength="2048"
:placeholder="
editLinks.discord.clear
? 'Existing link will be cleared'
: 'Enter a valid Discord invite URL'
"
/>
<Button
v-tooltip="'Clear link'"
aria-label="Clear link"
:data-active="editLinks.discord.clear"
icon-only
@click="editLinks.discord.clear = !editLinks.discord.clear"
>
<TrashIcon />
</Button>
</div>
</section>
<p>
Changes will be applied to
<strong>{{ selectedProjects.length }}</strong> project{{
selectedProjects.length > 1 ? 's' : ''
}}.
</p>
<ul>
<li
v-for="project in selectedProjects.slice(
0,
editLinks.showAffected ? selectedProjects.length : 3
)"
:key="project.id"
>
{{ project.name }}
</li>
<li v-if="!editLinks.showAffected && selectedProjects.length > 3">
<strong>and {{ selectedProjects.length - 3 }} more...</strong>
</li>
</ul>
<Checkbox
v-if="selectedProjects.length > 3"
v-model="editLinks.showAffected"
:label="editLinks.showAffected ? 'Less' : 'More'"
description="Show all loaders"
:border="false"
:collapsing-toggle-style="true"
/>
<div class="push-right input-group">
<Button @click="$refs.editLinksModal.hide()">
<XIcon />
Cancel
</Button>
<Button color="primary" @click="onBulkEditLinks">
<SaveIcon />
Save changes
</Button>
</div>
</div>
</Modal>
<ModalCreation ref="modal_creation" :organization-id="organization.id" />
<div class="universal-card">
<h2>Projects</h2>
<div class="input-group">
<Button color="primary" @click="$refs.modal_creation.show()">
<PlusIcon />
Create a project
</Button>
<OrganizationProjectTransferModal
:projects="userProjects || []"
@submit="onProjectTransferSubmit"
/>
</div>
<p v-if="sortedProjects.length < 1">
You don't have any projects yet. Click the green button above to begin.
</p>
<template v-else>
<p>You can edit multiple projects at once by selecting them below.</p>
<div class="input-group">
<Button :disabled="selectedProjects.length === 0" @click="$refs.editLinksModal.show()">
<EditIcon />
Edit links
</Button>
<div class="push-right">
<div class="labeled-control-row">
Sort by
<Multiselect
v-model="sortBy"
:searchable="false"
class="small-select"
:options="['Name', 'Status', 'Type']"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
@update:model-value="
sortedProjects = updateSort(sortedProjects, sortBy, descending)
"
/>
<Button
v-tooltip="descending ? 'Descending' : 'Ascending'"
class="square-button"
icon-only
@click="updateDescending()"
>
<SortDescendingIcon v-if="descending" />
<SortAscendingIcon v-else />
</Button>
</div>
</div>
</div>
<div class="table">
<div class="table-row table-head">
<div class="table-cell check-cell">
<Checkbox
:model-value="selectedProjects === sortedProjects"
@update:model-value="
selectedProjects === sortedProjects
? (selectedProjects = [])
: (selectedProjects = sortedProjects)
"
/>
</div>
<div class="table-cell">Icon</div>
<div class="table-cell">Name</div>
<div class="table-cell">ID</div>
<div class="table-cell">Type</div>
<div class="table-cell">Status</div>
<div class="table-cell" />
</div>
<div v-for="project in sortedProjects" :key="`project-${project.id}`" class="table-row">
<div class="table-cell check-cell">
<Checkbox
:disabled="(project.permissions & EDIT_DETAILS) === EDIT_DETAILS"
:model-value="selectedProjects.includes(project)"
@update:model-value="
selectedProjects.includes(project)
? (selectedProjects = selectedProjects.filter((it) => it !== project))
: selectedProjects.push(project)
"
/>
</div>
<div class="table-cell">
<nuxt-link tabindex="-1" :to="`/project/${project.slug ? project.slug : project.id}`">
<Avatar
:src="project.icon_url"
aria-hidden="true"
:alt="'Icon for ' + project.name"
no-shadow
/>
</nuxt-link>
</div>
<div class="table-cell">
<span class="project-title">
<IssuesIcon
v-if="project.moderator_message"
aria-label="Project has a message from the moderators. View the project to see more."
/>
<nuxt-link
class="hover-link wrap-as-needed"
:to="`/project/${project.slug ? project.slug : project.id}`"
>
{{ project.name }}
</nuxt-link>
</span>
</div>
<div class="table-cell">
<CopyCode :text="project.id" />
</div>
<div class="table-cell">
<BoxIcon />
<span>{{
$formatProjectType(
$getProjectTypeForDisplay(project.project_types[0] ?? 'project', project.loaders)
)
}}</span>
</div>
<div class="table-cell">
<Badge v-if="project.status" :type="project.status" class="status" />
</div>
<div class="table-cell">
<nuxt-link
class="btn icon-only"
:to="`/project/${project.slug ? project.slug : project.id}/settings`"
>
<SettingsIcon />
</nuxt-link>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { Multiselect } from 'vue-multiselect'
import {
Badge,
Checkbox,
BoxIcon,
Modal,
Avatar,
CopyCode,
SettingsIcon,
TrashIcon,
IssuesIcon,
PlusIcon,
XIcon,
EditIcon,
SaveIcon,
Button,
SortAscendingIcon,
SortDescendingIcon,
} from 'omorphia'
import ModalCreation from '~/components/ui/ModalCreation.vue'
import OrganizationProjectTransferModal from '~/components/ui/OrganizationProjectTransferModal.vue'
const { organization, projects, refresh } = inject('organizationContext')
const auth = await useAuth()
const { data: userProjects } = await useAsyncData(
`user/${auth.value.user.id}/projects`,
() => useBaseFetch(`user/${auth.value.user.id}/projects`),
{
watch: [auth],
}
)
const onProjectTransferSubmit = async (projects) => {
try {
for (const project of projects) {
await useBaseFetch(`organization/${organization.value.id}/projects`, {
method: 'POST',
body: JSON.stringify({
project_id: project.id,
}),
apiVersion: 3,
})
}
await refresh()
addNotification({
group: 'main',
title: 'Success',
text: 'Transferred selected projects to organization.',
type: 'success',
})
} catch (err) {
addNotification({
group: 'main',
title: 'An error occurred',
text: err?.data?.description || err?.message || err || 'Unknown error',
type: 'error',
})
console.error(err)
}
}
const EDIT_DETAILS = 1 << 2
const updateSort = (inputProjects, sort, descending) => {
let sortedArray = inputProjects
switch (sort) {
case 'Name':
sortedArray = inputProjects.slice().sort((a, b) => {
return a.name.localeCompare(b.name)
})
break
case 'Status':
sortedArray = inputProjects.slice().sort((a, b) => {
if (a.status < b.status) {
return -1
}
if (a.status > b.status) {
return 1
}
return 0
})
break
case 'Type':
sortedArray = inputProjects.slice().sort((a, b) => {
if (a.project_type < b.project_type) {
return -1
}
if (a.project_type > b.project_type) {
return 1
}
return 0
})
break
default:
break
}
if (descending) {
sortedArray = sortedArray.reverse()
}
return sortedArray
}
const sortedProjects = ref(updateSort(projects.value, 'Name'))
const selectedProjects = ref([])
const sortBy = ref('Name')
const descending = ref(false)
const editLinksModal = ref(null)
watch(
() => projects.value,
(newVal) => {
sortedProjects.value = updateSort(newVal, sortBy.value, descending.value)
}
)
const emptyLinksData = {
showAffected: false,
source: {
val: '',
clear: false,
},
discord: {
val: '',
clear: false,
},
wiki: {
val: '',
clear: false,
},
issues: {
val: '',
clear: false,
},
}
const editLinks = ref(emptyLinksData)
const updateDescending = () => {
descending.value = !descending.value
sortedProjects.value = updateSort(sortedProjects.value, sortBy.value, descending.value)
}
const onBulkEditLinks = useClientTry(async () => {
const linkData = editLinks.value
const baseData = {}
if (linkData.issues.clear) {
baseData.issues_url = null
} else if (linkData.issues.val.trim().length > 0) {
baseData.issues_url = linkData.issues.val.trim()
}
if (linkData.source.clear) {
baseData.source_url = null
} else if (linkData.source.val.trim().length > 0) {
baseData.source_url = linkData.source.val.trim()
}
if (linkData.wiki.clear) {
baseData.wiki_url = null
} else if (linkData.wiki.val.trim().length > 0) {
baseData.wiki_url = linkData.wiki.val.trim()
}
if (linkData.discord.clear) {
baseData.discord_url = null
} else if (linkData.discord.val.trim().length > 0) {
baseData.discord_url = linkData.discord.val.trim()
}
await useBaseFetch(`projects?ids=${JSON.stringify(selectedProjects.value.map((x) => x.id))}`, {
method: 'PATCH',
body: JSON.stringify(baseData),
})
editLinksModal.value.hide()
addNotification({
group: 'main',
title: 'Success',
text: "Bulk edited selected project's links.",
type: 'success',
})
selectedProjects.value = []
editLinks.value = emptyLinksData
})
</script>
<style lang="scss" scoped>
.table {
display: grid;
border-radius: var(--radius-md);
overflow: hidden;
margin-top: var(--gap-md);
border: 1px solid var(--color-button-bg);
background-color: var(--color-raised-bg);
.table-row {
grid-template-columns: 2.75rem 3.75rem 2fr 1fr 1fr 1fr 3.5rem;
}
.table-cell {
display: flex;
align-items: center;
gap: var(--gap-xs);
padding: var(--gap-md);
padding-left: 0;
}
.check-cell {
padding-left: var(--gap-md);
}
@media screen and (max-width: 750px) {
display: flex;
flex-direction: column;
.table-row {
display: grid;
grid-template: 'checkbox icon name type settings' 'checkbox icon id status settings';
grid-template-columns:
min-content min-content minmax(min-content, 2fr)
minmax(min-content, 1fr) min-content;
:nth-child(1) {
grid-area: checkbox;
}
:nth-child(2) {
grid-area: icon;
}
:nth-child(3) {
grid-area: name;
}
:nth-child(4) {
grid-area: id;
padding-top: 0;
}
:nth-child(5) {
grid-area: type;
}
:nth-child(6) {
grid-area: status;
padding-top: 0;
}
:nth-child(7) {
grid-area: settings;
}
}
.table-head {
grid-template: 'checkbox settings';
grid-template-columns: min-content minmax(min-content, 1fr);
:nth-child(2),
:nth-child(3),
:nth-child(4),
:nth-child(5),
:nth-child(6) {
display: none;
}
}
}
@media screen and (max-width: 560px) {
.table-row {
display: grid;
grid-template: 'checkbox icon name settings' 'checkbox icon id settings' 'checkbox icon type settings' 'checkbox icon status settings';
grid-template-columns: min-content min-content minmax(min-content, 1fr) min-content;
:nth-child(5) {
padding-top: 0;
}
}
.table-head {
grid-template: 'checkbox settings';
grid-template-columns: min-content minmax(min-content, 1fr);
}
}
}
.project-title {
display: flex;
flex-direction: row;
gap: var(--spacing-card-xs);
svg {
color: var(--color-special-orange);
}
}
.status {
margin-top: var(--spacing-card-xs);
}
.hover-link:hover {
text-decoration: underline;
}
.labeled-control-row {
flex: 1;
display: flex;
flex-direction: row;
min-width: 0;
align-items: center;
gap: var(--gap-sm);
white-space: nowrap;
}
.small-select {
width: fit-content;
width: -moz-fit-content;
}
.label-button[data-active='true'] {
--background-color: var(--color-special-red);
--text-color: var(--color-brand-inverted);
}
.links-modal {
.links {
display: grid;
gap: var(--spacing-card-sm);
grid-template-columns: 1fr 2fr;
.input-group {
flex-wrap: nowrap;
}
@media screen and (max-width: 530px) {
grid-template-columns: 1fr;
.input-group {
flex-wrap: wrap;
}
}
}
ul {
margin: 0 0 var(--spacing-card-sm) 0;
}
}
h1 {
margin-block: var(--gap-sm) var(--gap-lg);
font-size: 2em;
line-height: 1em;
}
:deep(.checkbox-outer) {
button.checkbox {
border: none;
}
}
</style>