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:
Mir Arif Hasan 2026-03-26 00:58:36 +06:00 committed by GitHub
parent 06bdd7ca6a
commit 59c1b595a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 203 additions and 8 deletions

View file

@ -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,

View file

@ -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',

View file

@ -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: {

View file

@ -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",

View 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>

View file

@ -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
}
}
}
}

View file

@ -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 () => {