You've already forked AstralRinth
forked from didirus/AstralRinth
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:
@@ -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>
|
||||
Reference in New Issue
Block a user