Allow org transfer (half tested)

This commit is contained in:
Nassim Jahnke 2022-07-31 20:49:52 +02:00 committed by MiniDigger | Martin
parent 15a3111659
commit b628a9afbd
7 changed files with 110 additions and 15 deletions

View File

@ -0,0 +1,66 @@
<script lang="ts" setup>
import { useI18n } from "vue-i18n";
import Button from "~/lib/components/design/Button.vue";
import Modal from "~/lib/components/modals/Modal.vue";
import { useContext } from "vite-ssr/vue";
import { useRouter } from "vue-router";
import { useApi, useInternalApi } from "~/composables/useApi";
import { handleRequestError } from "~/composables/useErrorHandling";
import InputAutocomplete from "~/lib/components/ui/InputAutocomplete.vue";
import { ref } from "vue";
import { PaginatedResult, User } from "hangar-api";
import { useNotificationStore } from "~/store/notification";
const props = defineProps<{
organization: string;
}>();
const i18n = useI18n();
const ctx = useContext();
const router = useRouter();
const notificationStore = useNotificationStore();
const search = ref<string>("");
const result = ref<string[]>([]);
const loading = ref<boolean>(false);
async function doSearch(val: string) {
result.value = [];
const users = await useApi<PaginatedResult<User>>("users", false, "get", {
query: val,
limit: 25,
offset: 0,
});
result.value = users.result.filter((u) => !u.isOrganization).map((u) => u.name);
}
async function transfer() {
loading.value = true;
try {
await useInternalApi<string>(`organizations/org/${props.organization}/transfer`, true, "post", {
content: search.value,
});
notificationStore.success(i18n.t("organization.settings.success.transferRequest", [search.value]));
} catch (e) {
handleRequestError(e, ctx, i18n);
}
loading.value = false;
}
</script>
<template>
<Modal :title="i18n.t('organization.settings.transferModal.title', [organization])" window-classes="w-150">
<template #default>
<p class="mb-2">{{ i18n.t("organization.settings.transferModal.description", [organization]) }}</p>
<div class="flex items-center">
<InputAutocomplete v-model="search" :values="result" :label="i18n.t('organization.settings.transferModal.transferTo')" @search="doSearch" />
<Button :disabled="search.length === 0" :loading="loading" class="ml-2" @click="transfer">
<IconMdiRenameBox class="mr-2" />
{{ i18n.t("project.settings.transfer") }}
</Button>
</div>
</template>
<template #activator="{ on }">
<Button size="small" class="mr-1" v-on="on"><IconMdiCogTransfer /></Button>
</template>
</Modal>
</template>

View File

@ -65,9 +65,7 @@ const canLeave = computed<boolean>(() => {
return props.members.some((member) => member.user.id === authStore.user?.id && member.user.id !== props.owner);
});
const canEdit = computed<boolean>(() => {
return hasPerms(NamedPermission.EDIT_SUBJECT_SETTINGS);
});
const canEdit = computed<boolean>(() => hasPerms(NamedPermission.EDIT_SUBJECT_SETTINGS));
const saving = ref<boolean>(false);
const search = ref<string>("");
const addErrors = ref<string[]>([]);

View File

@ -52,7 +52,7 @@
"noProjects": "There are no projects. 😢",
"noProjectsFound": "Found 0 projects. 😢",
"title": "Find your favorite plugins",
"subTitle": "Hangar allows you to find the best Velocity, Waterfall or Paper plugins for your Minecraft Servers.",
"subTitle": "Hangar allows you to find the best Paper, Velocity, or Waterfall plugins for your Minecraft server.",
"categories": "Categories",
"licenses": "Licenses",
"versions": "Minecraft versions",
@ -609,6 +609,14 @@
"notMember": "{0} is not a member of the organization, therefore you cannot edit their role",
"invalidRole": "{0} cannot be added/removed from the organization",
"pendingTransfer": "You already have a pending transfer"
},
"transferModal": {
"title": "Transfer {0} to another user",
"description": "After you click the transfer button, the selected user will receive an invitation to join {0} as the new owner. If they accept, you will be demoted to the secondary owner rank.",
"transferTo": "User"
},
"success": {
"transferRequest": "Successfully sent a transfer request to {0}. You can cancel this via the member list."
}
}
},
@ -712,6 +720,7 @@
"watching": "Watching",
"stars": "Stars",
"orgs": "Organizations",
"management": "Management",
"viewOnForums": "View on forums ",
"taglineLabel": "User Tagline",
"editTagline": "Edit Tagline",
@ -729,7 +738,8 @@
"unlock": "Unlock Account",
"apiKeys": "API Keys",
"activity": "User Activity",
"admin": "User Admin"
"admin": "User Admin",
"transfer": "Transfer organization to another user"
},
"lock": {
"confirmLock": "Lock {0}'s account?",

View File

@ -119,7 +119,6 @@ watch(selectedTab, (val) => history.replaceState({}, "", route.path + "#" + val)
const search = ref<string>("");
const result = ref<string[]>([]);
async function doSearch(val: string) {
//TODO also include orgs
result.value = [];
const users = await useApi<PaginatedResult<User>>("users", false, "get", {
query: val,

View File

@ -28,6 +28,7 @@ import IconMdiEyeOffOutline from "~icons/mdi/eye-off-outline";
import OrgVisibilityModal from "~/components/modals/OrgVisibilityModal.vue";
import LockUserModal from "~/components/modals/LockUserModal.vue";
import ProjectCard from "~/components/projects/ProjectCard.vue";
import OrgTransferModal from "~/components/modals/OrgTransferModal.vue";
const props = defineProps<{
user: User;
@ -61,9 +62,9 @@ const buttons = computed<UserButton[]>(() => {
if (hasPerms(NamedPermission.EDIT_ALL_USER_SETTINGS)) {
list.push({ icon: IconMdiKey, attr: { to: "/" + props.user.name + "/settings/api-keys" }, name: "apiKeys" });
}
}
if ((hasPerms(NamedPermission.MOD_NOTES_AND_FLAGS) || hasPerms(NamedPermission.REVIEWER)) && !props.user.isOrganization) {
list.push({ icon: IconMdiCalendar, attr: { to: `/admin/activities/${props.user.name}` }, name: "activity" });
if (hasPerms(NamedPermission.MOD_NOTES_AND_FLAGS) || hasPerms(NamedPermission.REVIEWER)) {
list.push({ icon: IconMdiCalendar, attr: { to: `/admin/activities/${props.user.name}` }, name: "activity" });
}
}
if (hasPerms(NamedPermission.EDIT_ALL_USER_SETTINGS)) {
list.push({ icon: IconMdiWrench, attr: { to: "/admin/user/" + props.user.name }, name: "admin" });
@ -91,7 +92,10 @@ useHead(useSeo(props.user.name, props.user.name + " is an author on Hangar. " +
</div>
<div class="flex-basis-full flex-grow md:max-w-1/3 md:min-w-1/3">
<Card v-if="buttons.length !== 0" class="mb-4 border-solid border-top-4 border-top-red-500 dark:border-top-red-500">
<template #header> Admin actions </template>
<template #header>{{ i18n.t("author.management") }}</template>
<Tooltip v-if="hasPerms(NamedPermission.IS_SUBJECT_OWNER)" :content="i18n.t('author.tooltips.transfer')">
<OrgTransferModal :organization="user.name" />
</Tooltip>
<Tooltip v-for="btn in buttons" :key="btn.name">
<template #content>

View File

@ -94,7 +94,7 @@ public class OrganizationController extends HangarComponent {
@RateLimit(overdraft = 7, refillTokens = 2, refillSeconds = 10)
@PermissionRequired(type = PermissionType.ORGANIZATION, perms = NamedPermission.EDIT_SUBJECT_SETTINGS, args = "{#name}")
@PostMapping(path = "/org/{name}/members/add", consumes = MediaType.APPLICATION_JSON_VALUE)
public void addProjectMember(@PathVariable String name, @Valid @RequestBody EditMembersForm.Member<OrganizationRole> member) {
public void addOrganizationMember(@PathVariable String name, @Valid @RequestBody EditMembersForm.Member<OrganizationRole> member) {
OrganizationTable organizationTable = organizationService.getOrganizationTable(name);
if (organizationTable == null) {
throw new HangarApiException("Org " + name + " doesn't exist");
@ -107,7 +107,7 @@ public class OrganizationController extends HangarComponent {
@RateLimit(overdraft = 5, refillTokens = 2, refillSeconds = 10)
@PermissionRequired(type = PermissionType.ORGANIZATION, perms = NamedPermission.EDIT_SUBJECT_SETTINGS, args = "{#name}")
@PostMapping(path = "/org/{name}/members/edit", consumes = MediaType.APPLICATION_JSON_VALUE)
public void editProjectMember(@PathVariable String name, @Valid @RequestBody EditMembersForm.Member<OrganizationRole> member) {
public void editOrganizationMember(@PathVariable String name, @Valid @RequestBody EditMembersForm.Member<OrganizationRole> member) {
OrganizationTable organizationTable = organizationService.getOrganizationTable(name);
memberService.editMember(member, organizationTable);
}
@ -117,7 +117,7 @@ public class OrganizationController extends HangarComponent {
@RateLimit(overdraft = 7, refillTokens = 2, refillSeconds = 10)
@PermissionRequired(type = PermissionType.ORGANIZATION, perms = NamedPermission.EDIT_SUBJECT_SETTINGS, args = "{#name}")
@PostMapping(path = "/org/{name}/members/remove", consumes = MediaType.APPLICATION_JSON_VALUE)
public void removeProjectMember(@PathVariable String name, @Valid @RequestBody EditMembersForm.Member<OrganizationRole> member) {
public void removeOrganizationMember(@PathVariable String name, @Valid @RequestBody EditMembersForm.Member<OrganizationRole> member) {
OrganizationTable organizationTable = organizationService.getOrganizationTable(name);
memberService.removeMember(member, organizationTable);
}
@ -126,11 +126,30 @@ public class OrganizationController extends HangarComponent {
@ResponseStatus(HttpStatus.OK)
@PermissionRequired(type = PermissionType.ORGANIZATION, perms = NamedPermission.IS_SUBJECT_MEMBER, args = "{#name}")
@PostMapping(path = "/org/{name}/members/leave", consumes = MediaType.APPLICATION_JSON_VALUE)
public void leaveProject(@PathVariable String name) {
public void leaveOrganization(@PathVariable String name) {
OrganizationTable organizationTable = organizationService.getOrganizationTable(name);
memberService.leave(organizationTable);
}
@Unlocked
@ResponseStatus(HttpStatus.OK)
@PermissionRequired(type = PermissionType.ORGANIZATION, perms = NamedPermission.IS_SUBJECT_OWNER, args = "{#name}")
@RateLimit(overdraft = 5, refillTokens = 1, refillSeconds = 60)
@PostMapping(path = "/org/{name}/transfer", consumes = MediaType.APPLICATION_JSON_VALUE)
public void transferProject(@PathVariable String name, @Valid @RequestBody StringContent nameContent) {
final OrganizationTable organizationTable = organizationService.getOrganizationTable(name);
inviteService.sendTransferRequest(nameContent.getContent(), organizationTable);
}
@Unlocked
@ResponseStatus(HttpStatus.OK)
@PermissionRequired(type = PermissionType.ORGANIZATION, perms = NamedPermission.IS_SUBJECT_OWNER, args = "{#name}")
@PostMapping(path = "/org/{name}/canceltransfer", consumes = MediaType.APPLICATION_JSON_VALUE)
public void transferProject(@PathVariable String name) {
final OrganizationTable organizationTable = organizationService.getOrganizationTable(name);
inviteService.cancelTransferRequest(organizationTable);
}
@Unlocked
@ResponseStatus(HttpStatus.OK)
@RateLimit(overdraft = 3, refillTokens = 1, refillSeconds = 60)

View File

@ -169,7 +169,6 @@ public class ProjectController extends HangarComponent {
@Unlocked
@ResponseStatus(HttpStatus.OK)
@PermissionRequired(type = PermissionType.PROJECT, perms = NamedPermission.IS_SUBJECT_OWNER, args = "{#author, #slug}")
@RateLimit(overdraft = 5, refillTokens = 1, refillSeconds = 60)
@PostMapping(path = "/project/{author}/{slug}/canceltransfer", consumes = MediaType.APPLICATION_JSON_VALUE)
public void transferProject(@PathVariable String author, @PathVariable String slug) {
final ProjectTable projectTable = projectService.getProjectTable(author, slug);