feat: show user workspace memberships in admin dashboard (#5968)
Co-authored-by: Anwarul Islam <anwaarulislaam@gmail.com> Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
parent
06bdd7ca6a
commit
59c1b595a6
7 changed files with 203 additions and 8 deletions
|
|
@ -45,7 +45,6 @@ const RESOLVERS = [
|
|||
AdminResolver,
|
||||
ShortcodeResolver,
|
||||
TeamResolver,
|
||||
TeamEnvsTeamResolver,
|
||||
TeamMemberResolver,
|
||||
TeamCollectionResolver,
|
||||
TeamTeamInviteExtResolver,
|
||||
|
|
@ -54,7 +53,6 @@ const RESOLVERS = [
|
|||
TeamInvitationResolver,
|
||||
TeamRequestResolver,
|
||||
UserResolver,
|
||||
UserCollectionResolver,
|
||||
UserEnvironmentsResolver,
|
||||
UserEnvsUserResolver,
|
||||
UserHistoryUserResolver,
|
||||
|
|
|
|||
|
|
@ -18,10 +18,14 @@ import { RequiresTeamRole } from './decorators/requires-team-role.decorator';
|
|||
import { GqlTeamMemberGuard } from './guards/gql-team-member.guard';
|
||||
import { PubSubService } from '../pubsub/pubsub.service';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import { throwErr } from 'src/utils';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
import { GqlAdminGuard } from 'src/admin/guards/gql-admin.guard';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import { USER_NOT_FOUND } from 'src/errors';
|
||||
|
||||
@UseGuards(GqlThrottlerGuard)
|
||||
@Resolver(() => Team)
|
||||
|
|
@ -29,6 +33,7 @@ export class TeamResolver {
|
|||
constructor(
|
||||
private readonly teamService: TeamService,
|
||||
private readonly pubsub: PubSubService,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
// Field Resolvers
|
||||
|
|
@ -141,6 +146,39 @@ export class TeamResolver {
|
|||
return this.teamService.getTeamWithID(teamID);
|
||||
}
|
||||
|
||||
@Query(() => [Team], {
|
||||
description: 'Returns the list of teams a user is a member of (admin-only)',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async teamsOfUserByAdmin(
|
||||
@Args({
|
||||
name: 'userUid',
|
||||
type: () => ID,
|
||||
description: 'UID of the user to fetch teams for',
|
||||
})
|
||||
userUid: string,
|
||||
@Args({
|
||||
name: 'cursor',
|
||||
type: () => ID,
|
||||
description:
|
||||
'The ID of the last returned team entry (used for pagination)',
|
||||
nullable: true,
|
||||
})
|
||||
cursor?: string,
|
||||
@Args({
|
||||
name: 'take',
|
||||
type: () => Int,
|
||||
description: 'Number of teams to return per page',
|
||||
nullable: true,
|
||||
defaultValue: 10,
|
||||
})
|
||||
take?: number,
|
||||
): Promise<Team[]> {
|
||||
const user = await this.userService.findUserById(userUid);
|
||||
if (O.isNone(user)) throwErr(USER_NOT_FOUND);
|
||||
return this.teamService.getTeamsOfUser(userUid, cursor ?? null, take);
|
||||
}
|
||||
|
||||
// Mutation
|
||||
@Mutation(() => Team, {
|
||||
description: 'Creates a team owned by the executing user',
|
||||
|
|
|
|||
|
|
@ -261,10 +261,14 @@ export class TeamService implements UserDataHandler, OnModuleInit {
|
|||
return E.right(team);
|
||||
}
|
||||
|
||||
async getTeamsOfUser(uid: string, cursor: string | null): Promise<Team[]> {
|
||||
async getTeamsOfUser(
|
||||
uid: string,
|
||||
cursor: string | null,
|
||||
take = 10,
|
||||
): Promise<Team[]> {
|
||||
if (!cursor) {
|
||||
const entries = await this.prisma.teamMember.findMany({
|
||||
take: 10,
|
||||
take,
|
||||
where: {
|
||||
userUid: uid,
|
||||
},
|
||||
|
|
@ -276,7 +280,7 @@ export class TeamService implements UserDataHandler, OnModuleInit {
|
|||
return entries.map((entry) => entry.team);
|
||||
} else {
|
||||
const entries = await this.prisma.teamMember.findMany({
|
||||
take: 10,
|
||||
take,
|
||||
skip: 1,
|
||||
cursor: {
|
||||
teamID_userUid: {
|
||||
|
|
|
|||
|
|
@ -432,6 +432,16 @@
|
|||
"valid_name": "Please enter a valid workspace name",
|
||||
"valid_owner_email": "Please enter a valid owner email"
|
||||
},
|
||||
"user_teams": {
|
||||
"load_error": "Unable to load workspace memberships",
|
||||
"no_teams": "This user does not belong to any workspaces",
|
||||
"role": "Role",
|
||||
"role_unknown": "Unknown",
|
||||
"show_more": "Show more",
|
||||
"title": "Workspaces",
|
||||
"workspace_id": "Workspace ID",
|
||||
"workspace_name": "Workspace Name"
|
||||
},
|
||||
"users": {
|
||||
"add_user": "Add User",
|
||||
"admin": "Admin",
|
||||
|
|
|
|||
112
packages/hoppscotch-sh-admin/src/components/users/Teams.vue
Normal file
112
packages/hoppscotch-sh-admin/src/components/users/Teams.vue
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<template>
|
||||
<div class="px-4 mt-7">
|
||||
<div v-if="fetching" class="flex justify-center">
|
||||
<HoppSmartSpinner />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error">{{ t('user_teams.load_error') }}</div>
|
||||
|
||||
<div v-else-if="teams.length === 0">
|
||||
{{ t('user_teams.no_teams') }}
|
||||
</div>
|
||||
|
||||
<HoppSmartTable v-else :headings="headings" :list="teams">
|
||||
<template #head>
|
||||
<th class="px-6 py-2">{{ t('user_teams.workspace_name') }}</th>
|
||||
<th class="px-6 py-2">{{ t('user_teams.workspace_id') }}</th>
|
||||
<th class="px-6 py-2">{{ t('user_teams.role') }}</th>
|
||||
</template>
|
||||
<template #body="{ row: team }">
|
||||
<td class="py-4 px-7 max-w-50">
|
||||
<RouterLink
|
||||
:to="`/teams/${team.id}`"
|
||||
class="text-accent hover:underline"
|
||||
>
|
||||
{{ team.name }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td class="py-4 px-7">
|
||||
<span class="font-mono text-secondaryDark text-sm">{{
|
||||
team.id
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="py-4 px-7">
|
||||
<span
|
||||
class="text-xs font-medium px-2.5 py-0.5 rounded-full"
|
||||
:class="roleBadgeClass(team.role)"
|
||||
>
|
||||
{{ team.role ?? t('user_teams.role_unknown') }}
|
||||
</span>
|
||||
</td>
|
||||
</template>
|
||||
</HoppSmartTable>
|
||||
|
||||
<div
|
||||
v-if="hasNextPage && teams.length >= teamsPerPage"
|
||||
class="flex items-center w-28 px-3 py-2 mt-5 mx-auto font-semibold text-secondaryDark bg-divider hover:bg-dividerDark rounded-3xl cursor-pointer"
|
||||
@click="fetchNextTeams"
|
||||
>
|
||||
<span class="mr-2">{{ t('user_teams.show_more') }}</span>
|
||||
<icon-lucide-chevron-down />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from '~/composables/i18n';
|
||||
import { usePagedQuery } from '~/composables/usePagedQuery';
|
||||
import {
|
||||
TeamsOfUserByAdminDocument,
|
||||
TeamAccessRole,
|
||||
} from '~/helpers/backend/graphql';
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
userUid: string;
|
||||
}>();
|
||||
|
||||
const teamsPerPage = 10;
|
||||
|
||||
const {
|
||||
fetching,
|
||||
error,
|
||||
goToNextPage: fetchNextTeams,
|
||||
list,
|
||||
hasNextPage,
|
||||
} = usePagedQuery(
|
||||
TeamsOfUserByAdminDocument,
|
||||
(x) => x.teamsOfUserByAdmin,
|
||||
teamsPerPage,
|
||||
{ userUid: props.userUid, cursor: undefined, take: teamsPerPage },
|
||||
(x) => x.id,
|
||||
);
|
||||
|
||||
// Flatten the team + role into a single row object
|
||||
const teams = computed(() =>
|
||||
list.value.map((team) => {
|
||||
const member = team.teamMembers.find((m) => m.user.uid === props.userUid);
|
||||
return { id: team.id, name: team.name, role: member?.role ?? null };
|
||||
}),
|
||||
);
|
||||
|
||||
const headings = [
|
||||
{ key: 'name', label: t('user_teams.workspace_name') },
|
||||
{ key: 'id', label: t('user_teams.workspace_id') },
|
||||
{ key: 'role', label: t('user_teams.role') },
|
||||
];
|
||||
|
||||
const roleBadgeClass = (role: TeamAccessRole | null) => {
|
||||
switch (role) {
|
||||
case TeamAccessRole.Owner:
|
||||
return 'bg-blue-900 text-blue-300';
|
||||
case TeamAccessRole.Editor:
|
||||
return 'bg-yellow-900 text-yellow-300';
|
||||
case TeamAccessRole.Viewer:
|
||||
return 'bg-gray-700 text-gray-300';
|
||||
default:
|
||||
return 'bg-gray-700 text-gray-300';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
query TeamsOfUserByAdmin($userUid: ID!, $cursor: ID, $take: Int) {
|
||||
teamsOfUserByAdmin(userUid: $userUid, cursor: $cursor, take: $take) {
|
||||
id
|
||||
name
|
||||
teamMembers {
|
||||
membershipID
|
||||
role
|
||||
user {
|
||||
uid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,13 @@
|
|||
<HoppSmartTab :id="'requests'" :label="t('shared_requests.title')">
|
||||
<UsersSharedRequests :email="user.email" />
|
||||
</HoppSmartTab>
|
||||
<HoppSmartTab :id="'teams'" :label="t('user_teams.title')">
|
||||
<UsersTeams
|
||||
v-if="hasOpenedTeamsTab"
|
||||
:user-uid="user.uid"
|
||||
class="py-8"
|
||||
/>
|
||||
</HoppSmartTab>
|
||||
</HoppSmartTabs>
|
||||
</div>
|
||||
|
||||
|
|
@ -62,7 +69,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { useMutation } from '@urql/vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from '~/composables/i18n';
|
||||
import { useToast } from '~/composables/toast';
|
||||
|
|
@ -79,8 +86,19 @@ const t = useI18n();
|
|||
const toast = useToast();
|
||||
|
||||
// Tabs
|
||||
type OptionTabs = 'details' | 'requests';
|
||||
type OptionTabs = 'details' | 'requests' | 'teams';
|
||||
const selectedOptionTab = ref<OptionTabs>('details');
|
||||
const hasOpenedTeamsTab = ref(false);
|
||||
|
||||
watch(
|
||||
selectedOptionTab,
|
||||
(tab) => {
|
||||
if (tab === 'teams') {
|
||||
hasOpenedTeamsTab.value = true;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const currentTabName = computed(() => {
|
||||
switch (selectedOptionTab.value) {
|
||||
|
|
@ -88,6 +106,8 @@ const currentTabName = computed(() => {
|
|||
return t('users.details');
|
||||
case 'requests':
|
||||
return t('shared_requests.title');
|
||||
case 'teams':
|
||||
return t('user_teams.title');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
|
@ -99,7 +119,7 @@ const { fetching, error, data, fetchData } = useClientHandler(
|
|||
UserInfoDocument,
|
||||
{
|
||||
uid: route.params.id.toString(),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue