Let you leave org/project

Closes #806
This commit is contained in:
Nassim Jahnke 2022-07-31 10:41:58 +02:00 committed by MiniDigger | Martin
parent ce91b3998b
commit a91c8f684a
15 changed files with 102 additions and 54 deletions

View File

@ -0,0 +1,45 @@
<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 { useInternalApi } from "~/composables/useApi";
import { handleRequestError } from "~/composables/useErrorHandling";
const props = withDefaults(
defineProps<{
author: string;
organization?: boolean;
slug?: string;
}>(),
{
organization: false,
slug: undefined,
}
);
const i18n = useI18n();
const ctx = useContext();
const router = useRouter();
const name = props.organization ? props.author : props.slug;
async function leave() {
const url = props.organization ? `organizations/org/${props.author}/members/leave` : `projects/project/${props.author}/${props.slug}/members/leave`;
useInternalApi(url, true, "post")
.then(() => router.go(0))
.catch((e) => handleRequestError(e, ctx, i18n));
}
</script>
<template>
<Modal :title="i18n.t('form.memberList.leaveModal.title', [name])" window-classes="w-150">
<template #default>
<p>{{ i18n.t("form.memberList.leaveModal.description", [name]) }}</p>
<Button class="mt-3" size="small" button-type="red" @click="leave()"> {{ i18n.t("form.memberList.leave") }} </Button>
</template>
<template #activator="{ on }">
<Button class="text-base" size="small" button-type="red" v-on="on"> {{ i18n.t("form.memberList.leave") }} </Button>
</template>
</Modal>
</template>

View File

@ -18,6 +18,8 @@ import { useContext } from "vite-ssr/vue";
import IconMdiClock from "~icons/mdi/clock";
import Tooltip from "~/lib/components/design/Tooltip.vue";
import InputAutocomplete from "~/lib/components/ui/InputAutocomplete.vue";
import { useAuthStore } from "~/store/auth";
import MemberLeaveModal from "~/components/modals/MemberLeaveModal.vue";
const props = withDefaults(
defineProps<{
@ -27,6 +29,7 @@ const props = withDefaults(
organization?: boolean;
author?: string;
slug?: string;
owner: number;
}>(),
{
organization: false,
@ -51,8 +54,16 @@ const store = useBackendDataStore();
const route = useRoute();
const router = useRouter();
const ctx = useContext();
const authStore = useAuthStore();
const roles: Role[] = props.organization ? store.orgRoles : store.projectRoles;
const canLeave = computed<boolean>(() => {
if (!authStore.user) {
return false;
}
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);
});
@ -113,7 +124,7 @@ function convertMember(member: JoinableMember): EditableMember {
}
async function doSearch(val: string) {
result.value = ["Dum", "Dum2"];
result.value = [];
const users = await useApi<PaginatedResult<User>>("users", false, "get", {
query: val,
limit: 25,
@ -133,6 +144,7 @@ interface EditableMember {
<template #header>
<div class="inline-flex w-full flex-cols space-between">
<h3 class="flex-grow">{{ i18n.t("project.members") }}</h3>
<MemberLeaveModal v-if="canLeave" :author="author" :organization="organization" :slug="slug" />
</div>
</template>

View File

@ -611,7 +611,12 @@
"addUser": "Add User...",
"create": "Create",
"editUser": "Edit User",
"invitedAs": "Pending invite as {0}"
"invitedAs": "Pending invite as {0}",
"leave": "Leave",
"leaveModal": {
"title": "Leave {0}",
"description": "Are you sure you want to leave {0}? After you leave, you can only rejoin after receiving a new invitation."
}
}
},
"notifications": {

View File

@ -131,7 +131,7 @@ function createPinnedVersionUrl(version: PinnedVersion): string {
</ul>
</Card>
<ProjectPageList :project="project" :open="openProjectPages" />
<MemberList :members="project.members" :author="project.owner.name" :slug="project.name" class="overflow-visible" />
<MemberList :members="project.members" :author="project.owner.name" :slug="project.name" :owner="project.owner.userId" class="overflow-visible" />
</section>
</div>
</template>

View File

@ -390,7 +390,13 @@ useHead(
</template>
</Tabs>
</Card>
<MemberList :members="project.members" :author="project.owner.name" :slug="project.name" class="basis-full md:basis-3/12 h-max" />
<MemberList
:members="project.members"
:author="project.owner.name"
:slug="project.name"
:owner="project.owner.userId"
class="basis-full md:basis-3/12 h-max"
/>
</div>
</template>

View File

@ -162,7 +162,7 @@ useHead(useSeo(props.user.name, props.user.name + " is an author on Hangar. " +
</ul>
</Card>
</template>
<MemberList v-else :members="organization.members" :roles="orgRoles" organization :author="user.name" />
<MemberList v-else :members="organization.members" :roles="orgRoles" organization :author="user.name" :owner="organization.owner.userId" />
</div>
</div>
</template>

View File

@ -78,12 +78,10 @@ async function markNotificationRead(notification: HangarNotification, router = t
}
}
async function updateInvite(invite: Invite, status: "accept" | "decline" | "unaccept") {
async function updateInvite(invite: Invite, status: "accept" | "decline") {
await useInternalApi(`invites/${invite.type}/${invite.roleTableId}/${status}`, true, "post").catch((e) => handleRequestError(e, ctx, i18n));
if (status === "accept") {
invite.accepted = true;
} else if (status === "unaccept") {
invite.accepted = false;
} else {
invites.value[invite.type] = invites.value[invite.type].filter((i) => i.roleTableId !== invite.roleTableId);
}
@ -151,10 +149,7 @@ function updateSelectedNotifications() {
<Card v-for="(invite, index) in filteredInvites" :key="index">
{{ i18n.t(!invite.accepted ? "notifications.invited" : "notifications.inviteAccepted", [invite.type]) }}:
<router-link :to="invite.url" exact>{{ invite.name }}</router-link>
<template v-if="invite.accepted">
<Button class="ml-2" @click="updateInvite(invite, 'unaccept')">{{ i18n.t("notifications.invite.btns.unaccept") }}</Button>
</template>
<template v-else>
<template v-if="!invite.accepted">
<Button class="mr-2 ml-2" @click="updateInvite(invite, 'accept')">{{ i18n.t("notifications.invite.btns.accept") }}</Button>
<Button @click="updateInvite(invite, 'decline')">{{ i18n.t("notifications.invite.btns.decline") }}</Button>
</template>

View File

@ -245,13 +245,11 @@ public class HangarUserController extends HangarComponent {
switch (status) {
case DECLINE -> inviteService.declineInvite(table);
case ACCEPT -> inviteService.acceptInvite(table);
case UNACCEPT -> inviteService.unacceptInvite(table);
}
}
public enum InviteStatus {
ACCEPT,
DECLINE,
UNACCEPT,
DECLINE
}
}

View File

@ -1,6 +1,5 @@
package io.papermc.hangar.controller.internal;
import com.fasterxml.jackson.core.JsonProcessingException;
import io.papermc.hangar.HangarComponent;
import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.model.common.NamedPermission;
@ -46,8 +45,6 @@ import org.springframework.web.multipart.MultipartFile;
import javax.validation.Valid;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Map;
import java.util.Optional;
@ -125,6 +122,15 @@ public class OrganizationController extends HangarComponent {
memberService.removeMember(member, organizationTable);
}
@Unlocked
@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) {
OrganizationTable organizationTable = organizationService.getOrganizationTable(name);
memberService.leave(organizationTable);
}
@Unlocked
@ResponseStatus(HttpStatus.OK)
@RateLimit(overdraft = 3, refillTokens = 1, refillSeconds = 60)

View File

@ -185,6 +185,15 @@ public class ProjectController extends HangarComponent {
projectMemberService.removeMember(member, projectTable);
}
@Unlocked
@ResponseStatus(HttpStatus.OK)
@PermissionRequired(type = PermissionType.PROJECT, perms = NamedPermission.IS_SUBJECT_MEMBER, args = "{#author, #slug}")
@PostMapping(path = "/project/{author}/{slug}/members/leave", consumes = MediaType.APPLICATION_JSON_VALUE)
public void leaveProject(@PathVariable String author, @PathVariable String slug) {
ProjectTable projectTable = projectService.getProjectTable(author, slug);
projectMemberService.leave(projectTable);
}
@Unlocked
@VisibilityRequired(type = Type.PROJECT, args = "{#projectId}")
@RateLimit(overdraft = 5, refillTokens = 1, refillSeconds = 10)

View File

@ -65,7 +65,6 @@ public class LogAction<LC extends LogContext<? extends LoggedActionTable, LC>> {
// TODO create organization
public static final LogAction<OrganizationContext> ORGANIZATION_INVITES_SENT = new LogAction<>(PGLoggedAction.ORGANIZATION_INVITES_SENT, "Organization Invites Sent");
public static final LogAction<OrganizationContext> ORGANIZATION_INVITE_DECLINED = new LogAction<>(PGLoggedAction.ORGANIZATION_INVITE_DECLINED, "Organization Invite Declined");
public static final LogAction<OrganizationContext> ORGANIZATION_INVITE_UNACCEPTED = new LogAction<>(PGLoggedAction.ORGANIZATION_INVITE_UNACCEPTED, "Organization Invite Unaccepted");
public static final LogAction<OrganizationContext> ORGANIZATION_MEMBER_ADDED = new LogAction<>(PGLoggedAction.ORGANIZATION_MEMBER_ADDED, "Organization Member Added");
public static final LogAction<OrganizationContext> ORGANIZATION_MEMBERS_REMOVED = new LogAction<>(PGLoggedAction.ORGANIZATION_MEMBERS_REMOVED, "Organization Members Removed");
public static final LogAction<OrganizationContext> ORGANIZATION_MEMBER_ROLES_CHANGED = new LogAction<>(PGLoggedAction.ORGANIZATION_MEMBER_ROLES_CHANGED, "Organization Member Roles Changed");

View File

@ -20,7 +20,6 @@ import io.papermc.hangar.service.internal.users.notifications.JoinableNotificati
import org.jetbrains.annotations.Nullable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Transactional
@ -80,14 +79,16 @@ public abstract class MemberService<
membersDao.insert(constructor.create(roleTable.getUserId(), roleTable.getPrincipalId()));
}
// TODO user's removing themselves from projects/organizations
@Transactional
public void removeMember(RT roleTable, String userName, boolean removeRole) {
membersDao.delete(roleTable.getPrincipalId(), roleTable.getUserId());
if (removeRole) {
roleService.deleteRole(roleTable);
public void leave(final J joinable) {
final RT role = roleService.getRole(joinable.getId(), getHangarUserId());
if (invalidRolesToChange().contains(role.getRole())) {
throw new HangarApiException(this.errorPrefix + "invalidRole", role.getRole().getTitle());
}
logMemberRemoval(roleTable, "Removed:" + userName + " (" + roleTable.getRole().getTitle() + ")");
membersDao.delete(role.getPrincipalId(), role.getUserId());
roleService.deleteRole(role);
logMemberRemoval(role, "Left:" + getHangarPrincipal().getName() + " (" + role.getRole().getTitle() + ")");
}
@Transactional

View File

@ -19,7 +19,6 @@ import io.papermc.hangar.service.internal.users.NotificationService;
import io.papermc.hangar.service.internal.users.notifications.JoinableNotificationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
@ -112,22 +111,6 @@ public abstract class InviteService<LC extends LogContext<?, LC>, R extends Role
roleTable.logAction(actionLogger, getInviteAcceptAction(), userTable.getName() + " accepted an invite for " + roleTable.getRole().getTitle(), roleTable.getCreatedAt().format(DateTimeFormatter.RFC_1123_DATE_TIME));
}
public void unacceptInvite(RT roleTable) {
if (!roleTable.isAccepted()) {
throw new IllegalArgumentException("Cannot un-accept a non-accepted invite");
}
roleTable = roleService.changeAcceptance(roleTable, false);
UserTable userTable = userDAO.getUserTable(roleTable.getUserId());
memberService.removeMember(roleTable, userTable.getName(), false);
logInviteUnaccepted(roleTable, userTable);
}
abstract LogAction<LC> getInviteUnacceptAction();
protected void logInviteUnaccepted(RT roleTable, UserTable userTable) {
roleTable.logAction(actionLogger, getInviteUnacceptAction(), userTable.getName() + " unaccepted an invite for " + roleTable.getRole().getTitle(), roleTable.getCreatedAt().format(DateTimeFormatter.RFC_1123_DATE_TIME));
}
public void declineInvite(RT roleTable) {
roleService.deleteRole(roleTable);
logInviteDeclined(roleTable, userDAO.getUserTable(roleTable.getUserId()));

View File

@ -36,11 +36,6 @@ public class OrganizationInviteService extends InviteService<OrganizationContext
return LogAction.ORGANIZATION_MEMBER_ADDED;
}
@Override
LogAction<OrganizationContext> getInviteUnacceptAction() {
return LogAction.ORGANIZATION_INVITE_UNACCEPTED;
}
@Override
LogAction<OrganizationContext> getInviteDeclineAction() {
return LogAction.ORGANIZATION_INVITE_DECLINED;

View File

@ -11,7 +11,6 @@ import io.papermc.hangar.service.internal.perms.roles.ProjectRoleService;
import io.papermc.hangar.service.internal.users.notifications.JoinableNotificationService.ProjectNotificationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@ -36,11 +35,6 @@ public class ProjectInviteService extends InviteService<ProjectContext, ProjectR
return LogAction.PROJECT_MEMBER_ADDED;
}
@Override
LogAction<ProjectContext> getInviteUnacceptAction() {
return LogAction.PROJECT_INVITE_UNACCEPTED;
}
@Override
LogAction<ProjectContext> getInviteDeclineAction() {
return LogAction.PROJECT_INVITE_DECLINED;