Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[dashboard] use list members and get invitation api #18920

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions components/dashboard/src/components/AuthorizeGit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ import { AuthProviderInfo } from "@gitpod/gitpod-protocol";
import { FC, useCallback, useContext } from "react";
import { Link } from "react-router-dom";
import { useAuthProviders } from "../data/auth-providers/auth-provider-query";
import { useCurrentOrg } from "../data/organizations/orgs-query";
import { openAuthorizeWindow } from "../provider-utils";
import { getGitpodService } from "../service/service";
import { UserContext, useCurrentUser } from "../user-context";
import { Button } from "./Button";
import { Heading2, Heading3, Subheading } from "./typography/headings";
import classNames from "classnames";
import { iconForAuthProvider, simplifyProviderName } from "../provider-utils";
import { useOrgMembersInfoQuery } from "../data/organizations/org-members-info-query";

export function useNeedsGitAuthorization() {
const authProviders = useAuthProviders();
Expand All @@ -28,7 +28,7 @@ export function useNeedsGitAuthorization() {

export const AuthorizeGit: FC<{ className?: string }> = ({ className }) => {
const { setUser } = useContext(UserContext);
const org = useCurrentOrg();
const orgMembersInfo = useOrgMembersInfoQuery();
const authProviders = useAuthProviders();
const updateUser = useCallback(() => {
getGitpodService().server.getLoggedInUser().then(setUser);
Expand Down Expand Up @@ -57,7 +57,7 @@ export const AuthorizeGit: FC<{ className?: string }> = ({ className }) => {
{verifiedProviders.length === 0 ? (
<>
<Heading3 className="pb-2">No Git integrations</Heading3>
{!!org.data?.isOwner ? (
{!!orgMembersInfo.data?.isOwner ? (
<div className="px-6">
<Subheading>You need to configure at least one Git integration.</Subheading>
<Link to="/settings/git">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Button } from "./Button";
import { useCreateHoldPaymentIntentMutation } from "../data/billing/create-hold-payment-intent-mutation";
import { useToast } from "./toasts/Toasts";
import { ProgressBar } from "./ProgressBar";
import { useOrgMembersInfoQuery } from "../data/organizations/org-members-info-query";

const BASE_USAGE_LIMIT_FOR_STRIPE_USERS = 1000;

Expand All @@ -33,6 +34,7 @@ let didAlreadyCallSubscribe = false;

export default function UsageBasedBillingConfig({ hideSubheading = false }: Props) {
const currentOrg = useCurrentOrg().data;
const orgMembersInfo = useOrgMembersInfoQuery().data;
const attrId = currentOrg ? AttributionId.create(currentOrg) : undefined;
const attributionId = attrId && AttributionId.render(attrId);
const [showUpdateLimitModal, setShowUpdateLimitModal] = useState<boolean>(false);
Expand Down Expand Up @@ -154,8 +156,8 @@ export default function UsageBasedBillingConfig({ hideSubheading = false }: Prop
// Pick a good initial value for the Stripe usage limit (base_limit * team_size)
// FIXME: Should we ask the customer to confirm or edit this default limit?
let limit = BASE_USAGE_LIMIT_FOR_STRIPE_USERS;
if (attrId?.kind === "team" && currentOrg) {
limit = BASE_USAGE_LIMIT_FOR_STRIPE_USERS * currentOrg.members.length;
if (attrId?.kind === "team" && orgMembersInfo) {
limit = BASE_USAGE_LIMIT_FOR_STRIPE_USERS * orgMembersInfo.members.length;
}
const newLimit = await getGitpodService().server.subscribeToStripe(
attributionId,
Expand Down Expand Up @@ -190,7 +192,7 @@ export default function UsageBasedBillingConfig({ hideSubheading = false }: Prop
);
}
},
[attrId?.kind, attributionId, currentOrg, location.pathname, refreshSubscriptionDetails],
[attrId?.kind, attributionId, orgMembersInfo, location.pathname, refreshSubscriptionDetails],
);

const showSpinner = !attributionId || isLoadingStripeSubscription || isCreatingSubscription;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { useQuery, useQueryClient } from "@tanstack/react-query";
import { OldOrganizationInfo, getOldQueryKey, useCurrentOrg } from "./orgs-query";
import { teamsService } from "../../service/public-api";
import { Code, ConnectError } from "@connectrpc/connect";
import { useCurrentUser } from "../../user-context";

export const useOrgInvitationQuery = () => {
const user = useCurrentUser();
const org = useCurrentOrg().data;
const queryClient = useQueryClient();

return useQuery<{ invitationId?: string }>({
queryKey: getOrgInvitationQueryKey(org?.id ?? ""),
staleTime: 1000 * 60 * 2, // 2 minute
queryFn: async () => {
if (!org) {
throw new Error("No org selected.");
}
try {
const resp = await teamsService.getTeamInvitation({ teamId: org.id });
return { invitationId: resp.teamInvitation?.id };
} catch (err) {
const e = ConnectError.from(err);
if (e.code === Code.Unimplemented) {
const data = queryClient.getQueryData<OldOrganizationInfo[]>(getOldQueryKey(user));
const foundOrg = data?.find((orgInfo) => orgInfo.id === org.id);
return { invitationId: foundOrg?.invitationId };
}
throw err;
}
},
enabled: !!org,
});
};

export const getOrgInvitationQueryKey = (orgId: string) => ["org-invitation", { orgId }];
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { OrgMemberInfo } from "@gitpod/gitpod-protocol";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { OldOrganizationInfo, getOldQueryKey, useCurrentOrg } from "./orgs-query";
import { publicApiTeamMembersToProtocol, teamsService } from "../../service/public-api";
import { useCurrentUser } from "../../user-context";
import { TeamRole } from "@gitpod/public-api/lib/gitpod/experimental/v1/teams_pb";
import { Code, ConnectError } from "@connectrpc/connect";

export interface OrgMembersInfo {
members: OrgMemberInfo[];
isOwner: boolean;
}

export const useOrgMembersInfoQuery = () => {
const user = useCurrentUser();
const org = useCurrentOrg().data;
const queryClient = useQueryClient();

return useQuery<OrgMembersInfo>({
queryKey: getOrgMembersInfoQueryKey(org?.id ?? "", user?.id ?? ""),
staleTime: 1000 * 60 * 1, // 1 minute
queryFn: async () => {
if (!org) {
throw new Error("No org selected.");
}
try {
const resp = await teamsService.listTeamMembers({ teamId: org.id });
return {
members: publicApiTeamMembersToProtocol(resp.members),
isOwner:
resp.members.findIndex(
(member) => member.userId === user?.id && member.role === TeamRole.OWNER,
) >= 0,
};
} catch (err) {
const e = ConnectError.from(err);
if (e.code === Code.Unimplemented) {
const data = queryClient.getQueryData<OldOrganizationInfo[]>(getOldQueryKey(user));
const foundOrg = data?.find((orgInfo) => orgInfo.id === org.id);
return {
members: foundOrg?.members ?? [],
isOwner: foundOrg?.isOwner ?? false,
};
}
throw err;
}
},
enabled: !!org && !!user,
});
};

export const getOrgMembersInfoQueryKey = (orgId: string, userId: string) => ["org-members", { orgId, userId }];
75 changes: 65 additions & 10 deletions components/dashboard/src/data/organizations/orgs-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,35 +11,46 @@ import { useLocation } from "react-router";
import { publicApiTeamMembersToProtocol, publicApiTeamToProtocol, teamsService } from "../../service/public-api";
import { useCurrentUser } from "../../user-context";
import { noPersistence } from "../setup";
import { getOrgInvitationQueryKey } from "./org-invitation-query";
import { getOrgMembersInfoQueryKey } from "./org-members-info-query";
import { Code, ConnectError } from "@connectrpc/connect";

export interface OrganizationInfo extends Organization {
export interface OldOrganizationInfo extends Organization {
members: OrgMemberInfo[];
isOwner: boolean;
invitationId?: string;
}

export type OrganizationInfo = Pick<Organization, "id" | "name" | "slug">;

export function useOrganizationsInvalidator() {
const user = useCurrentUser();
const org = useCurrentOrg();
const queryClient = useQueryClient();
return useCallback(() => {
console.log("Invalidating orgs... " + JSON.stringify(getQueryKey(user)));
queryClient.invalidateQueries(getQueryKey(user));
}, [user, queryClient]);
queryClient.invalidateQueries(getOldQueryKey(user));
if (org.data?.id && user?.id) {
queryClient.invalidateQueries(getOrgInvitationQueryKey(org.data.id));
queryClient.invalidateQueries(getOrgMembersInfoQueryKey(org.data.id, user.id));
}
}, [user, org.data, queryClient]);
}

export function useOrganizations() {
export function useOldOrganizationsQuery() {
const user = useCurrentUser();
const query = useQuery<OrganizationInfo[], Error>(
getQueryKey(user),
const query = useQuery<OldOrganizationInfo[], Error>(
getOldQueryKey(user),
async () => {
console.log("Fetching orgs... " + JSON.stringify(getQueryKey(user)));
console.log("Fetching orgs with old api... " + JSON.stringify(getOldQueryKey(user)));
if (!user) {
console.log("useOrganizations with empty user");
console.log("useOldOrganizationsQuery with empty user");
return [];
}

const response = await teamsService.listTeams({});
const result: OrganizationInfo[] = [];
const result: OldOrganizationInfo[] = [];
for (const org of response.teams) {
const members = publicApiTeamMembersToProtocol(org.members || []);
const isOwner = members.some((m) => m.role === "owner" && m.userId === user?.id);
Expand All @@ -63,8 +74,52 @@ export function useOrganizations() {
return query;
}

function getQueryKey(user?: User) {
return noPersistence(["organizations", user?.id]);
export function useOrganizations() {
const user = useCurrentUser();
const queryClient = useQueryClient();
const query = useQuery<OrganizationInfo[], Error>(
getQueryKey(user),
async () => {
console.log("Fetching orgs... " + JSON.stringify(getQueryKey(user)));
if (!user) {
console.log("useOrganizations with empty user");
return [];
}
try {
const response = await teamsService.getTeamList({});
return response.teams;
} catch (err) {
const e = ConnectError.from(err);
if (e.code === Code.Unimplemented) {
const data = queryClient.getQueryData<OldOrganizationInfo[]>(getOldQueryKey(user));
return (
data?.map((org) => ({
id: org.id,
name: org.name,
slug: org.slug,
})) ?? []
);
}
throw err;
}
},
{
enabled: !!user,
cacheTime: 1000 * 60 * 60 * 1, // 1 hour
staleTime: 1000 * 60 * 60 * 1, // 1 hour
// We'll let an ErrorBoundary catch the error
useErrorBoundary: true,
},
);
return query;
}

export function getQueryKey(user?: User) {
return noPersistence(["new-organizations", user?.id]);
}

export function getOldQueryKey(user?: User) {
return noPersistence(["old-organizations", user?.id]);
}

// Custom hook to return the current org if one is selected
Expand Down
14 changes: 9 additions & 5 deletions components/dashboard/src/menu/OrganizationSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import { useCurrentOrg, useOrganizations } from "../data/organizations/orgs-quer
import { useLocation } from "react-router";
import { User } from "@gitpod/gitpod-protocol";
import { useOrgBillingMode } from "../data/billing-mode/org-billing-mode-query";
import { useOrgMembersInfoQuery } from "../data/organizations/org-members-info-query";

export default function OrganizationSelector() {
const user = useCurrentUser();
const orgs = useOrganizations();
const currentOrg = useCurrentOrg();
const orgMembersInfo = useOrgMembersInfoQuery().data;
const { data: billingMode } = useOrgBillingMode();
const getOrgURL = useGetOrgURL();

Expand All @@ -39,9 +41,9 @@ export default function OrganizationSelector() {
<CurrentOrgEntry
title={currentOrg.data.name}
subtitle={
!!currentOrg.data.members
? `${currentOrg.data.members.length} member${
currentOrg.data.members.length === 1 ? "" : "s"
!!orgMembersInfo?.members
? `${orgMembersInfo.members.length} member${
orgMembersInfo.members.length === 1 ? "" : "s"
}`
: "..."
}
Expand Down Expand Up @@ -71,7 +73,7 @@ export default function OrganizationSelector() {
link: "/usage",
});
// Show billing if user is an owner of current org
if (currentOrg.data.isOwner) {
if (orgMembersInfo?.isOwner) {
if (billingMode?.mode === "usage-based") {
linkEntries.push({
title: "Billing",
Expand Down Expand Up @@ -107,7 +109,9 @@ export default function OrganizationSelector() {
id={org.id}
title={org.name}
subtitle={
!!org.members ? `${org.members.length} member${org.members.length === 1 ? "" : "s"}` : "..."
!!orgMembersInfo?.members
? `${orgMembersInfo.members.length} member${orgMembersInfo.members.length === 1 ? "" : "s"}`
: "..."
}
/>
),
Expand Down
Loading
Loading