feat: confirm transfer project/org modals (#5532)
* feat: implement confirm transfer project/org modals * pnpm prepr * update warning banner copy * update warning banner again
This commit is contained in:
113
apps/frontend/src/components/ui/ConfirmTransferOrgModal.vue
Normal file
113
apps/frontend/src/components/ui/ConfirmTransferOrgModal.vue
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<template>
|
||||||
|
<NewModal ref="modal" fade="warning" width="550px">
|
||||||
|
<template #title>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-lg font-extrabold text-contrast">Transfer</span>
|
||||||
|
<Avatar :src="organization.icon_url" :alt="organization.name" size="xs" />
|
||||||
|
<span class="text-lg font-extrabold text-contrast">{{ organization.name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<Admonition type="warning" header="Beware of scams">
|
||||||
|
Do not transfer organizations to buyers. This is a common scam and against our TOS. If you
|
||||||
|
encounter a buyer, please
|
||||||
|
<a
|
||||||
|
href="https://support.modrinth.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="underline"
|
||||||
|
>contact support</a
|
||||||
|
>.
|
||||||
|
</Admonition>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[1fr_auto_1fr] items-center justify-center gap-6 rounded-2xl bg-surface-2 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Avatar :src="currentOwner.avatar_url" :alt="currentOwner.username" size="xs" circle />
|
||||||
|
<div class="flex flex-col items-start justify-start gap-1">
|
||||||
|
<span class="font-medium text-contrast">{{ currentOwner.username }}</span>
|
||||||
|
<span class="text-sm text-secondary">{{ currentOwner.role }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RightArrowIcon class="h-6 w-6 text-secondary" />
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Avatar :src="transferTo.avatar_url" :alt="transferTo.username" size="xs" circle />
|
||||||
|
<div class="flex flex-col items-start justify-start gap-1">
|
||||||
|
<span class="font-medium text-contrast">{{ transferTo.username }} </span>
|
||||||
|
<span class="text-sm text-secondary">{{ transferTo.role }} </span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="m-0 flex flex-col gap-1 pl-6 text-secondary">
|
||||||
|
<li>You will immediately lose owner access to this organization</li>
|
||||||
|
<li>The new owner can modify or delete the organization and all its projects</li>
|
||||||
|
<li>This action cannot be undone</li>
|
||||||
|
</ul>
|
||||||
|
<div>
|
||||||
|
<p class="m-0 mb-2">
|
||||||
|
To confirm this transfer, type
|
||||||
|
<span class="font-bold text-contrast">{{ organization.name }}</span> below
|
||||||
|
</p>
|
||||||
|
<StyledInput
|
||||||
|
v-model="confirmationText"
|
||||||
|
:placeholder="`Enter ${organization.name}`"
|
||||||
|
wrapper-class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<ButtonStyled>
|
||||||
|
<button @click="hide">
|
||||||
|
<XIcon />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled color="red">
|
||||||
|
<button :disabled="!isConfirmEnabled" @click="onConfirmClick">
|
||||||
|
<TransferIcon />
|
||||||
|
Transfer ownership
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RightArrowIcon, TransferIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import { Admonition, Avatar, ButtonStyled, NewModal, StyledInput } from '@modrinth/ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
organization: { name: string; icon_url: string | null }
|
||||||
|
currentOwner: { avatar_url: string | null; username: string; role: string }
|
||||||
|
transferTo: { avatar_url: string | null; username: string; role: string }
|
||||||
|
onConfirm: () => void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
const confirmationText = ref('')
|
||||||
|
|
||||||
|
const isConfirmEnabled = computed(
|
||||||
|
() =>
|
||||||
|
!!props.organization.name &&
|
||||||
|
confirmationText.value.toLowerCase().trim() === props.organization.name.toLowerCase().trim(),
|
||||||
|
)
|
||||||
|
|
||||||
|
function show(e?: MouseEvent) {
|
||||||
|
confirmationText.value = ''
|
||||||
|
modal.value?.show(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
modal.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfirmClick() {
|
||||||
|
hide()
|
||||||
|
props.onConfirm()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show, hide })
|
||||||
|
</script>
|
||||||
125
apps/frontend/src/components/ui/ConfirmTransferProjectModal.vue
Normal file
125
apps/frontend/src/components/ui/ConfirmTransferProjectModal.vue
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<NewModal ref="modal" fade="warning" width="550px">
|
||||||
|
<template #title>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-lg font-extrabold text-contrast">Transfer</span>
|
||||||
|
<Avatar :src="project.icon_url" :alt="project.name" size="xs" />
|
||||||
|
<span class="text-lg font-extrabold text-contrast">{{ project.name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<Admonition type="warning" header="Beware of scams">
|
||||||
|
Do not transfer projects to buyers. This is a common scam and against our TOS. If you
|
||||||
|
encounter a buyer, please
|
||||||
|
<a
|
||||||
|
href="https://support.modrinth.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="underline"
|
||||||
|
>contact support</a
|
||||||
|
>.
|
||||||
|
</Admonition>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[1fr_auto_1fr] items-center justify-center gap-6 rounded-2xl bg-surface-2 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Avatar :src="currentOwner.avatar_url" :alt="currentOwner.username" size="xs" circle />
|
||||||
|
<div class="flex flex-col items-start justify-start gap-1">
|
||||||
|
<span class="font-medium text-contrast">{{ currentOwner.username }}</span>
|
||||||
|
<span class="text-sm text-secondary">{{ currentOwner.role }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RightArrowIcon class="h-6 w-6 text-secondary" />
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Avatar
|
||||||
|
:src="transferTo.avatar_url"
|
||||||
|
:alt="transferTo.username || transferTo.name"
|
||||||
|
size="xs"
|
||||||
|
circle
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col items-start justify-start gap-1">
|
||||||
|
<span class="font-medium text-contrast">
|
||||||
|
{{ transferTo.username || transferTo.name }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-secondary">{{ transferTo.role || 'Member' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="m-0 flex flex-col gap-1 pl-6 text-secondary">
|
||||||
|
<li>You will immediately lose owner access to this project</li>
|
||||||
|
<li>The new owner can modify or delete the project at any time</li>
|
||||||
|
<li>This action cannot be undone</li>
|
||||||
|
</ul>
|
||||||
|
<div>
|
||||||
|
<p class="m-0 mb-2">
|
||||||
|
To confirm this transfer, type
|
||||||
|
<span class="font-bold text-contrast">{{ project.name }}</span> below
|
||||||
|
</p>
|
||||||
|
<StyledInput
|
||||||
|
v-model="confirmationText"
|
||||||
|
:placeholder="`Enter ${project.name}`"
|
||||||
|
wrapper-class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<ButtonStyled>
|
||||||
|
<button @click="hide">
|
||||||
|
<XIcon />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled color="red">
|
||||||
|
<button :disabled="!isConfirmEnabled" @click="onConfirmClick">
|
||||||
|
<TransferIcon />
|
||||||
|
Transfer ownership
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RightArrowIcon, TransferIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import { Admonition, Avatar, ButtonStyled, NewModal, StyledInput } from '@modrinth/ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
project: { name: string; icon_url: string | null }
|
||||||
|
currentOwner: { avatar_url: string | null; username: string; role: string }
|
||||||
|
transferTo: {
|
||||||
|
avatar_url?: string | null
|
||||||
|
username?: string
|
||||||
|
name?: string
|
||||||
|
role?: string
|
||||||
|
}
|
||||||
|
onConfirm: () => void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
const confirmationText = ref('')
|
||||||
|
|
||||||
|
const isConfirmEnabled = computed(
|
||||||
|
() =>
|
||||||
|
!!props.project.name &&
|
||||||
|
confirmationText.value.toLowerCase().trim() === props.project.name.toLowerCase().trim(),
|
||||||
|
)
|
||||||
|
|
||||||
|
function show(e?: MouseEvent) {
|
||||||
|
confirmationText.value = ''
|
||||||
|
modal.value?.show(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide() {
|
||||||
|
modal.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfirmClick() {
|
||||||
|
hide()
|
||||||
|
props.onConfirm()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show, hide })
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<ConfirmTransferProjectModal
|
||||||
|
v-if="transferData && project"
|
||||||
|
ref="transferModal"
|
||||||
|
:project="{ name: project.title, icon_url: project.icon_url }"
|
||||||
|
:current-owner="{
|
||||||
|
avatar_url: currentMember?.avatar_url,
|
||||||
|
username: currentMember?.user?.username ?? '',
|
||||||
|
role: currentMember?.role ?? 'Owner',
|
||||||
|
}"
|
||||||
|
:transfer-to="transferData.target"
|
||||||
|
:on-confirm="transferData.onConfirm"
|
||||||
|
/>
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
ref="modal_remove"
|
ref="modal_remove"
|
||||||
title="Are you sure you want to remove this project from the organization?"
|
title="Are you sure you want to remove this project from the organization?"
|
||||||
@@ -233,7 +245,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="!member.is_owner && currentMember?.is_owner && member.accepted"
|
v-if="!member.is_owner && currentMember?.is_owner && member.accepted"
|
||||||
class="iconified-button"
|
class="iconified-button"
|
||||||
@click="transferOwnership(index)"
|
@click="openTransferModal(index, $event)"
|
||||||
>
|
>
|
||||||
<TransferIcon />
|
<TransferIcon />
|
||||||
Transfer ownership
|
Transfer ownership
|
||||||
@@ -298,7 +310,11 @@
|
|||||||
:options="organizations || []"
|
:options="organizations || []"
|
||||||
:disabled="!currentMember?.is_owner || organizations?.length === 0"
|
:disabled="!currentMember?.is_owner || organizations?.length === 0"
|
||||||
/>
|
/>
|
||||||
<button class="btn btn-primary" :disabled="!selectedOrganization" @click="onAddToOrg">
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="!selectedOrganization"
|
||||||
|
@click="openTransferToOrgModal($event)"
|
||||||
|
>
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
Transfer management
|
Transfer management
|
||||||
</button>
|
</button>
|
||||||
@@ -553,6 +569,7 @@ import {
|
|||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { Multiselect } from 'vue-multiselect'
|
import { Multiselect } from 'vue-multiselect'
|
||||||
|
|
||||||
|
import ConfirmTransferProjectModal from '~/components/ui/ConfirmTransferProjectModal.vue'
|
||||||
import { removeSelfFromTeam } from '~/helpers/teams.js'
|
import { removeSelfFromTeam } from '~/helpers/teams.js'
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
@@ -604,6 +621,8 @@ initMembers()
|
|||||||
const currentUsername = ref('')
|
const currentUsername = ref('')
|
||||||
const openTeamMembers = ref([])
|
const openTeamMembers = ref([])
|
||||||
const selectedOrganization = ref(null)
|
const selectedOrganization = ref(null)
|
||||||
|
const transferData = ref(null)
|
||||||
|
const transferModal = ref(null)
|
||||||
|
|
||||||
const { data: organizations } = useAsyncData('organizations', () => {
|
const { data: organizations } = useAsyncData('organizations', () => {
|
||||||
return useBaseFetch('user/' + auth.value?.user.id + '/organizations', {
|
return useBaseFetch('user/' + auth.value?.user.id + '/organizations', {
|
||||||
@@ -758,6 +777,34 @@ const updateTeamMember = async (index) => {
|
|||||||
stopLoading()
|
stopLoading()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openTransferModal = (index, e) => {
|
||||||
|
transferData.value = {
|
||||||
|
target: {
|
||||||
|
avatar_url: allTeamMembers.value[index]?.avatar_url,
|
||||||
|
username: allTeamMembers.value[index]?.user?.username,
|
||||||
|
role: allTeamMembers.value[index]?.role || 'Member',
|
||||||
|
},
|
||||||
|
onConfirm: () => transferOwnership(index),
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
transferModal.value?.show(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTransferToOrgModal = (e) => {
|
||||||
|
if (!selectedOrganization.value) return
|
||||||
|
transferData.value = {
|
||||||
|
target: {
|
||||||
|
avatar_url: selectedOrganization.value.icon_url,
|
||||||
|
name: selectedOrganization.value.name,
|
||||||
|
},
|
||||||
|
onConfirm: () => onAddToOrg(),
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
transferModal.value?.show(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const transferOwnership = async (index) => {
|
const transferOwnership = async (index) => {
|
||||||
startLoading()
|
startLoading()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="normal-page__content">
|
<div class="normal-page__content">
|
||||||
|
<ConfirmTransferOrgModal
|
||||||
|
v-if="transferTargetMember"
|
||||||
|
ref="transferModal"
|
||||||
|
:organization="{ name: organization.name, icon_url: organization.icon_url }"
|
||||||
|
:current-owner="{
|
||||||
|
avatar_url: currentMember.user?.avatar_url,
|
||||||
|
username: currentMember.user?.username ?? '',
|
||||||
|
role: currentMember.role ?? 'Owner',
|
||||||
|
}"
|
||||||
|
:transfer-to="{
|
||||||
|
avatar_url: transferTargetMember.user?.avatar_url,
|
||||||
|
username: transferTargetMember.user?.username ?? '',
|
||||||
|
role: transferTargetMember.role || 'Member',
|
||||||
|
}"
|
||||||
|
:on-confirm="() => onTransferOwnership(organization.team_id, transferTargetMember.user.id)"
|
||||||
|
/>
|
||||||
<div class="universal-card">
|
<div class="universal-card">
|
||||||
<div class="label">
|
<div class="label">
|
||||||
<h3>
|
<h3>
|
||||||
@@ -205,7 +221,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="!member.is_owner && currentMember.is_owner && member.accepted"
|
v-if="!member.is_owner && currentMember.is_owner && member.accepted"
|
||||||
@click="() => onTransferOwnership(organization.team_id, member.user.id)"
|
@click="(e) => openTransferModal(member, e)"
|
||||||
>
|
>
|
||||||
<TransferIcon />
|
<TransferIcon />
|
||||||
Transfer ownership
|
Transfer ownership
|
||||||
@@ -233,8 +249,9 @@ import {
|
|||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
StyledInput,
|
StyledInput,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { ref } from 'vue'
|
import { nextTick, ref } from 'vue'
|
||||||
|
|
||||||
|
import ConfirmTransferOrgModal from '~/components/ui/ConfirmTransferOrgModal.vue'
|
||||||
import { removeTeamMember } from '~/helpers/teams.js'
|
import { removeTeamMember } from '~/helpers/teams.js'
|
||||||
import { injectOrganizationContext } from '~/providers/organization-context.ts'
|
import { injectOrganizationContext } from '~/providers/organization-context.ts'
|
||||||
import { isPermission } from '~/utils/permissions.ts'
|
import { isPermission } from '~/utils/permissions.ts'
|
||||||
@@ -246,6 +263,8 @@ const auth = await useAuth()
|
|||||||
|
|
||||||
const currentUsername = ref('')
|
const currentUsername = ref('')
|
||||||
const openTeamMembers = ref([])
|
const openTeamMembers = ref([])
|
||||||
|
const transferTargetMember = ref(null)
|
||||||
|
const transferModal = ref(null)
|
||||||
|
|
||||||
const allTeamMembers = ref(organization.value.members)
|
const allTeamMembers = ref(organization.value.members)
|
||||||
|
|
||||||
@@ -344,6 +363,13 @@ const onUpdateTeamMember = useClientTry(async (teamId, member) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const openTransferModal = (member, e) => {
|
||||||
|
transferTargetMember.value = member
|
||||||
|
nextTick(() => {
|
||||||
|
transferModal.value?.show(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const onTransferOwnership = useClientTry(async (teamId, uid) => {
|
const onTransferOwnership = useClientTry(async (teamId, uid) => {
|
||||||
const data = {
|
const data = {
|
||||||
user_id: uid,
|
user_id: uid,
|
||||||
|
|||||||
@@ -904,7 +904,7 @@ pub async fn transfer_ownership(
|
|||||||
&& project_item.inner.organization_id.is_some()
|
&& project_item.inner.organization_id.is_some()
|
||||||
{
|
{
|
||||||
return Err(ApiError::InvalidInput(
|
return Err(ApiError::InvalidInput(
|
||||||
"You cannot transfer ownership of a project team that is owend by an organization"
|
"You cannot transfer ownership of a project team that is owned by an organization"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user