mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-07 19:28:09 +00:00
Add Github Bulk Team Sync
This commit is contained in:
@@ -126,4 +126,81 @@ export const registerGithubOrgSyncRouter = async (server: FastifyZodProvider) =>
|
|||||||
return { githubOrgSyncConfig };
|
return { githubOrgSyncConfig };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/sync-all-teams",
|
||||||
|
method: "POST",
|
||||||
|
config: {
|
||||||
|
rateLimit: writeLimit
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
schema: {
|
||||||
|
body: z.object({
|
||||||
|
githubOrgAccessToken: z.string().trim().max(1000).optional()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
syncedUsersCount: z.number(),
|
||||||
|
skippedUsersCount: z.number(),
|
||||||
|
totalUsers: z.number(),
|
||||||
|
errors: z.array(z.string()),
|
||||||
|
createdTeams: z.array(z.string()),
|
||||||
|
updatedTeams: z.array(z.string()),
|
||||||
|
removedMemberships: z.number(),
|
||||||
|
syncDuration: z.number()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async (req) => {
|
||||||
|
const result = await server.services.githubOrgSync.syncAllTeams({
|
||||||
|
orgPermission: req.permission,
|
||||||
|
githubOrgAccessToken: req.body.githubOrgAccessToken
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
syncedUsersCount: result.syncedUsersCount,
|
||||||
|
skippedUsersCount: result.skippedUsersCount,
|
||||||
|
totalUsers: result.totalUsers,
|
||||||
|
errors: result.errors,
|
||||||
|
createdTeams: result.createdTeams,
|
||||||
|
updatedTeams: result.updatedTeams,
|
||||||
|
removedMemberships: result.removedMemberships,
|
||||||
|
syncDuration: result.syncDuration
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/validate-token",
|
||||||
|
method: "POST",
|
||||||
|
config: {
|
||||||
|
rateLimit: writeLimit
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
schema: {
|
||||||
|
body: z.object({
|
||||||
|
githubOrgAccessToken: z.string().trim().min(1, "GitHub access token is required")
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
valid: z.boolean(),
|
||||||
|
organizationInfo: z.object({
|
||||||
|
id: z.number(),
|
||||||
|
login: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
publicRepos: z.number().optional(),
|
||||||
|
privateRepos: z.number().optional()
|
||||||
|
}).optional()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async (req) => {
|
||||||
|
const result = await server.services.githubOrgSync.validateGithubToken({
|
||||||
|
orgPermission: req.permission,
|
||||||
|
githubOrgAccessToken: req.body.githubOrgAccessToken
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/return-await */
|
||||||
|
/* eslint-disable no-await-in-loop */
|
||||||
import { ForbiddenError } from "@casl/ability";
|
import { ForbiddenError } from "@casl/ability";
|
||||||
import { Octokit } from "@octokit/core";
|
import { Octokit } from "@octokit/core";
|
||||||
import { paginateGraphql } from "@octokit/plugin-paginate-graphql";
|
import { paginateGraphql } from "@octokit/plugin-paginate-graphql";
|
||||||
@@ -7,8 +9,10 @@ import { OrgMembershipRole } from "@app/db/schemas";
|
|||||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||||
import { groupBy } from "@app/lib/fn";
|
import { groupBy } from "@app/lib/fn";
|
||||||
import { logger } from "@app/lib/logger";
|
import { logger } from "@app/lib/logger";
|
||||||
|
import { TIdentityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal";
|
||||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||||
|
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
|
||||||
|
|
||||||
import { TGroupDALFactory } from "../group/group-dal";
|
import { TGroupDALFactory } from "../group/group-dal";
|
||||||
import { TUserGroupMembershipDALFactory } from "../group/user-group-membership-dal";
|
import { TUserGroupMembershipDALFactory } from "../group/user-group-membership-dal";
|
||||||
@@ -16,10 +20,28 @@ import { TLicenseServiceFactory } from "../license/license-service";
|
|||||||
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
|
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||||
import { TPermissionServiceFactory } from "../permission/permission-service-types";
|
import { TPermissionServiceFactory } from "../permission/permission-service-types";
|
||||||
import { TGithubOrgSyncDALFactory } from "./github-org-sync-dal";
|
import { TGithubOrgSyncDALFactory } from "./github-org-sync-dal";
|
||||||
import { TCreateGithubOrgSyncDTO, TDeleteGithubOrgSyncDTO, TUpdateGithubOrgSyncDTO } from "./github-org-sync-types";
|
import {
|
||||||
|
TCreateGithubOrgSyncDTO,
|
||||||
|
TDeleteGithubOrgSyncDTO,
|
||||||
|
TSyncAllTeamsDTO,
|
||||||
|
TSyncResult,
|
||||||
|
TUpdateGithubOrgSyncDTO,
|
||||||
|
TValidateGithubTokenDTO
|
||||||
|
} from "./github-org-sync-types";
|
||||||
|
|
||||||
const OctokitWithPlugin = Octokit.plugin(paginateGraphql);
|
const OctokitWithPlugin = Octokit.plugin(paginateGraphql);
|
||||||
|
|
||||||
|
// Type definitions for GitHub API errors
|
||||||
|
interface GitHubApiError extends Error {
|
||||||
|
status?: number;
|
||||||
|
response?: {
|
||||||
|
status?: number;
|
||||||
|
headers?: {
|
||||||
|
"x-ratelimit-reset"?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type TGithubOrgSyncServiceFactoryDep = {
|
type TGithubOrgSyncServiceFactoryDep = {
|
||||||
githubOrgSyncDAL: TGithubOrgSyncDALFactory;
|
githubOrgSyncDAL: TGithubOrgSyncDALFactory;
|
||||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
||||||
@@ -30,6 +52,8 @@ type TGithubOrgSyncServiceFactoryDep = {
|
|||||||
>;
|
>;
|
||||||
groupDAL: Pick<TGroupDALFactory, "insertMany" | "transaction" | "find">;
|
groupDAL: Pick<TGroupDALFactory, "insertMany" | "transaction" | "find">;
|
||||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||||
|
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find">;
|
||||||
|
identityMetadataDAL: TIdentityMetadataDALFactory;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TGithubOrgSyncServiceFactory = ReturnType<typeof githubOrgSyncServiceFactory>;
|
export type TGithubOrgSyncServiceFactory = ReturnType<typeof githubOrgSyncServiceFactory>;
|
||||||
@@ -40,7 +64,9 @@ export const githubOrgSyncServiceFactory = ({
|
|||||||
kmsService,
|
kmsService,
|
||||||
userGroupMembershipDAL,
|
userGroupMembershipDAL,
|
||||||
groupDAL,
|
groupDAL,
|
||||||
licenseService
|
licenseService,
|
||||||
|
orgMembershipDAL,
|
||||||
|
identityMetadataDAL
|
||||||
}: TGithubOrgSyncServiceFactoryDep) => {
|
}: TGithubOrgSyncServiceFactoryDep) => {
|
||||||
const createGithubOrgSync = async ({
|
const createGithubOrgSync = async ({
|
||||||
githubOrgName,
|
githubOrgName,
|
||||||
@@ -344,11 +370,469 @@ export const githubOrgSyncServiceFactory = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validateGithubToken = async ({ orgPermission, githubOrgAccessToken }: TValidateGithubTokenDTO) => {
|
||||||
|
const { permission } = await permissionService.getOrgPermission(
|
||||||
|
orgPermission.type,
|
||||||
|
orgPermission.id,
|
||||||
|
orgPermission.orgId,
|
||||||
|
orgPermission.authMethod,
|
||||||
|
orgPermission.orgId
|
||||||
|
);
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.GithubOrgSync);
|
||||||
|
|
||||||
|
const plan = await licenseService.getPlan(orgPermission.orgId);
|
||||||
|
if (!plan.githubOrgSync) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message:
|
||||||
|
"Failed to validate GitHub token due to plan restriction. Upgrade plan to use GitHub organization sync."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await githubOrgSyncDAL.findOne({ orgId: orgPermission.orgId });
|
||||||
|
if (!config) {
|
||||||
|
throw new BadRequestError({ message: "GitHub organization sync is not configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const testOctokit = new OctokitRest({
|
||||||
|
auth: githubOrgAccessToken,
|
||||||
|
request: {
|
||||||
|
signal: AbortSignal.timeout(10000)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: org } = await testOctokit.rest.orgs.get({
|
||||||
|
org: config.githubOrgName
|
||||||
|
});
|
||||||
|
|
||||||
|
const octokitGraphQL = new OctokitWithPlugin({
|
||||||
|
auth: githubOrgAccessToken,
|
||||||
|
request: {
|
||||||
|
signal: AbortSignal.timeout(10000)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await octokitGraphQL.graphql(`query { organization(login: $org) { id name } }`, { org: config.githubOrgName });
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
organizationInfo: {
|
||||||
|
id: org.id,
|
||||||
|
login: org.login,
|
||||||
|
name: org.name || org.login,
|
||||||
|
publicRepos: org.public_repos,
|
||||||
|
privateRepos: org.owned_private_repos || 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, `GitHub token validation failed for org ${config.githubOrgName}`);
|
||||||
|
|
||||||
|
const gitHubError = error as GitHubApiError;
|
||||||
|
const statusCode = gitHubError.status || gitHubError.response?.status;
|
||||||
|
if (statusCode) {
|
||||||
|
if (statusCode === 401) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "GitHub access token is invalid or expired."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (statusCode === 403) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "GitHub access token lacks required permissions. Ensure it has 'read:org' and 'read:user' scopes."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (statusCode === 404) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Organization '${config.githubOrgName}' not found or access token does not have access to it.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `GitHub token validation failed: ${(error as Error).message}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncAllTeams = async ({ orgPermission, githubOrgAccessToken }: TSyncAllTeamsDTO): Promise<TSyncResult> => {
|
||||||
|
const { permission } = await permissionService.getOrgPermission(
|
||||||
|
orgPermission.type,
|
||||||
|
orgPermission.id,
|
||||||
|
orgPermission.orgId,
|
||||||
|
orgPermission.authMethod,
|
||||||
|
orgPermission.orgId
|
||||||
|
);
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.GithubOrgSync);
|
||||||
|
|
||||||
|
const plan = await licenseService.getPlan(orgPermission.orgId);
|
||||||
|
if (!plan.githubOrgSync) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message:
|
||||||
|
"Failed to sync all GitHub teams due to plan restriction. Upgrade plan to use GitHub organization sync."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await githubOrgSyncDAL.findOne({ orgId: orgPermission.orgId });
|
||||||
|
if (!config || !config?.isActive) {
|
||||||
|
throw new BadRequestError({ message: "GitHub organization sync is not configured or not active" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({
|
||||||
|
type: KmsDataKey.Organization,
|
||||||
|
orgId: orgPermission.orgId
|
||||||
|
});
|
||||||
|
|
||||||
|
let orgAccessToken: string;
|
||||||
|
let shouldUpdateStoredToken = false;
|
||||||
|
|
||||||
|
// If a new token is provided, use it and update the stored token
|
||||||
|
if (githubOrgAccessToken) {
|
||||||
|
orgAccessToken = githubOrgAccessToken;
|
||||||
|
shouldUpdateStoredToken = true;
|
||||||
|
} else if (config.encryptedGithubOrgAccessToken) {
|
||||||
|
// Use the stored token
|
||||||
|
orgAccessToken = decryptor({ cipherTextBlob: config.encryptedGithubOrgAccessToken }).toString();
|
||||||
|
} else {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "GitHub organization access token is required for bulk sync. Please provide a token."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const testOctokit = new OctokitRest({
|
||||||
|
auth: orgAccessToken,
|
||||||
|
request: {
|
||||||
|
signal: AbortSignal.timeout(10000)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await testOctokit.rest.orgs.get({
|
||||||
|
org: config.githubOrgName
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shouldUpdateStoredToken) {
|
||||||
|
await githubOrgSyncDAL.updateById(config.id, {
|
||||||
|
encryptedGithubOrgAccessToken: encryptor({ plainText: Buffer.from(orgAccessToken) }).cipherTextBlob
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!githubOrgAccessToken && config.encryptedGithubOrgAccessToken) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Stored GitHub access token is invalid or expired. Please provide a new token."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Invalid GitHub access token or insufficient permissions: ${(error as Error).message}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all organization members
|
||||||
|
const orgMembers = await orgMembershipDAL.find({ orgId: orgPermission.orgId });
|
||||||
|
const activeMembers = orgMembers.filter((member) => member.status === "accepted" && member.isActive);
|
||||||
|
|
||||||
|
// Get GitHub usernames from metadata for all users
|
||||||
|
const userMetadata = await identityMetadataDAL.find({
|
||||||
|
orgId: orgPermission.orgId,
|
||||||
|
key: "github_username"
|
||||||
|
});
|
||||||
|
|
||||||
|
const githubUsernameMap = new Map<string, string>();
|
||||||
|
userMetadata.forEach((meta) => {
|
||||||
|
if (meta.userId) {
|
||||||
|
githubUsernameMap.set(meta.userId, meta.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
let syncedUsersCount = 0;
|
||||||
|
let skippedUsersCount = 0;
|
||||||
|
const syncErrors: string[] = [];
|
||||||
|
const createdTeams = new Set<string>();
|
||||||
|
const updatedTeams = new Set<string>();
|
||||||
|
let totalRemovedMemberships = 0;
|
||||||
|
|
||||||
|
const delay = (ms: number) =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
setTimeout(() => resolve(), ms);
|
||||||
|
});
|
||||||
|
|
||||||
|
const retryWithBackoff = async <T>(fn: () => Promise<T>, maxRetries = 3, baseDelay = 1000): Promise<T> => {
|
||||||
|
let lastError: Error;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error as Error;
|
||||||
|
const gitHubError = error as GitHubApiError;
|
||||||
|
const statusCode = gitHubError.status || gitHubError.response?.status;
|
||||||
|
if (statusCode === 403) {
|
||||||
|
const rateLimitReset = gitHubError.response?.headers?.["x-ratelimit-reset"];
|
||||||
|
if (rateLimitReset) {
|
||||||
|
const resetTime = parseInt(rateLimitReset, 10) * 1000;
|
||||||
|
const waitTime = Math.max(resetTime - Date.now(), baseDelay);
|
||||||
|
logger.warn(`Rate limit hit, waiting ${waitTime}ms until reset`);
|
||||||
|
await delay(Math.min(waitTime, 60000)); // Cap at 1 minute
|
||||||
|
} else {
|
||||||
|
await delay(baseDelay * 2 ** attempt);
|
||||||
|
}
|
||||||
|
} else if (attempt < maxRetries) {
|
||||||
|
await delay(baseDelay * 2 ** attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError!;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RATE_LIMIT_DELAY = 150;
|
||||||
|
|
||||||
|
const octokit = new OctokitWithPlugin({
|
||||||
|
auth: orgAccessToken,
|
||||||
|
request: {
|
||||||
|
signal: AbortSignal.timeout(30000)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncUserGroupsWithStoredUsername = async (
|
||||||
|
orgId: string,
|
||||||
|
userId: string,
|
||||||
|
githubUsername: string,
|
||||||
|
githubOrgName: string,
|
||||||
|
octokitInstance: InstanceType<typeof OctokitWithPlugin>
|
||||||
|
): Promise<{ createdTeams: string[]; updatedTeams: string[]; removedMemberships: number } | null> => {
|
||||||
|
const infisicalUserGroups = await userGroupMembershipDAL.findGroupMembershipsByUserIdInOrg(userId, orgId);
|
||||||
|
const infisicalUserGroupSet = new Set(infisicalUserGroups.map((el) => el.groupName));
|
||||||
|
|
||||||
|
const data = await octokitInstance.graphql
|
||||||
|
.paginate<{
|
||||||
|
organization: { teams: { totalCount: number; edges: { node: { name: string; description: string } }[] } };
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
query orgTeams($cursor: String,$org: String!, $username: String!){
|
||||||
|
organization(login: $org) {
|
||||||
|
teams(first: 100, userLogins: [$username], after: $cursor) {
|
||||||
|
totalCount
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
org: githubOrgName,
|
||||||
|
username: githubUsername
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error(err, `GitHub GraphQL error for user ${githubUsername}`);
|
||||||
|
|
||||||
|
const gitHubError = err as GitHubApiError;
|
||||||
|
const statusCode = gitHubError.status || gitHubError.response?.status;
|
||||||
|
if (statusCode) {
|
||||||
|
if (statusCode === 401) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "GitHub access token is invalid or expired. Please provide a new token."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (statusCode === 403) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message:
|
||||||
|
"GitHub access token lacks required permissions. Please ensure the token has 'read:org' and 'read:user' scopes."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (statusCode === 404) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Organization ${config.githubOrgName} not found or user ${githubUsername} is not a member.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((err as Error)?.message?.includes("Although you appear to have the correct authorization credential")) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message:
|
||||||
|
"Please check your organization have approved Infisical Oauth application. For more info: https://infisical.com/docs/documentation/platform/github-org-sync#troubleshooting"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new BadRequestError({ message: (err as Error)?.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
organization: { teams }
|
||||||
|
} = data;
|
||||||
|
const githubUserTeams = teams?.edges?.map((el) => el.node.name.toLowerCase()) || [];
|
||||||
|
const githubUserTeamSet = new Set(githubUserTeams);
|
||||||
|
const githubUserTeamOnInfisical = await groupDAL.find({ orgId, $in: { name: githubUserTeams } });
|
||||||
|
const githubUserTeamOnInfisicalGroupByName = groupBy(githubUserTeamOnInfisical, (i) => i.name);
|
||||||
|
|
||||||
|
const newTeams = githubUserTeams.filter(
|
||||||
|
(el) => !infisicalUserGroupSet.has(el) && !(el in githubUserTeamOnInfisicalGroupByName)
|
||||||
|
);
|
||||||
|
const updateTeams = githubUserTeams.filter(
|
||||||
|
(el) => !infisicalUserGroupSet.has(el) && el in githubUserTeamOnInfisicalGroupByName
|
||||||
|
);
|
||||||
|
const removeFromTeams = infisicalUserGroups.filter((el) => !githubUserTeamSet.has(el.groupName));
|
||||||
|
|
||||||
|
if (newTeams.length || updateTeams.length || removeFromTeams.length) {
|
||||||
|
return await groupDAL.transaction(async (tx) => {
|
||||||
|
const result = {
|
||||||
|
createdTeams: [] as string[],
|
||||||
|
updatedTeams: [] as string[],
|
||||||
|
removedMemberships: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (newTeams.length) {
|
||||||
|
logger.info({ userId, githubUsername, newTeams, orgId }, "Creating new teams for user");
|
||||||
|
|
||||||
|
const newGroups = await groupDAL.insertMany(
|
||||||
|
newTeams.map((newGroupName) => ({
|
||||||
|
name: newGroupName,
|
||||||
|
role: OrgMembershipRole.Member,
|
||||||
|
slug: newGroupName,
|
||||||
|
orgId
|
||||||
|
})),
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
await userGroupMembershipDAL.insertMany(
|
||||||
|
newGroups.map((el) => ({
|
||||||
|
groupId: el.id,
|
||||||
|
userId
|
||||||
|
})),
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
result.createdTeams = newTeams;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateTeams.length) {
|
||||||
|
logger.info({ userId, githubUsername, updateTeams, orgId }, "Adding user to existing teams");
|
||||||
|
|
||||||
|
await userGroupMembershipDAL.insertMany(
|
||||||
|
updateTeams.map((el) => ({
|
||||||
|
groupId: githubUserTeamOnInfisicalGroupByName[el][0].id,
|
||||||
|
userId
|
||||||
|
})),
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
result.updatedTeams = updateTeams;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removeFromTeams.length) {
|
||||||
|
logger.info(
|
||||||
|
{ userId, githubUsername, removeFromTeams: removeFromTeams.map((t) => t.groupName), orgId },
|
||||||
|
"Removing user from teams"
|
||||||
|
);
|
||||||
|
|
||||||
|
await userGroupMembershipDAL.delete(
|
||||||
|
{ userId, $in: { groupId: removeFromTeams.map((el) => el.groupId) } },
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
result.removedMemberships = removeFromTeams.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, `Failed to update team memberships for user ${userId} (${githubUsername})`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const member of activeMembers) {
|
||||||
|
try {
|
||||||
|
if (!member.userId) {
|
||||||
|
skippedUsersCount += 1;
|
||||||
|
syncErrors.push("Member without userId found, skipping");
|
||||||
|
// eslint-disable-next-line no-continue
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const githubUsername = githubUsernameMap.get(member.userId);
|
||||||
|
|
||||||
|
if (!githubUsername) {
|
||||||
|
skippedUsersCount += 1;
|
||||||
|
syncErrors.push(`User ${member.userId}: No GitHub username found. User needs to log in at least once.`);
|
||||||
|
// eslint-disable-next-line no-continue
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncResult = await retryWithBackoff(async () => {
|
||||||
|
return syncUserGroupsWithStoredUsername(
|
||||||
|
orgPermission.orgId,
|
||||||
|
member.userId!,
|
||||||
|
githubUsername,
|
||||||
|
config.githubOrgName,
|
||||||
|
octokit
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (syncResult) {
|
||||||
|
syncResult.createdTeams.forEach((team) => createdTeams.add(team));
|
||||||
|
syncResult.updatedTeams.forEach((team) => updatedTeams.add(team));
|
||||||
|
totalRemovedMemberships += syncResult.removedMemberships;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncedUsersCount += 1;
|
||||||
|
|
||||||
|
await delay(RATE_LIMIT_DELAY);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, `Failed to sync teams for user ${member.userId || "unknown"}`);
|
||||||
|
syncErrors.push(`User ${member.userId || "unknown"}: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncDuration = Date.now() - startTime;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
orgId: orgPermission.orgId,
|
||||||
|
syncedUsersCount,
|
||||||
|
skippedUsersCount,
|
||||||
|
totalUsers: activeMembers.length,
|
||||||
|
createdTeams: createdTeams.size,
|
||||||
|
updatedTeams: updatedTeams.size,
|
||||||
|
removedMemberships: totalRemovedMemberships,
|
||||||
|
syncDuration,
|
||||||
|
errorCount: syncErrors.length
|
||||||
|
},
|
||||||
|
"GitHub team sync completed"
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
syncedUsersCount,
|
||||||
|
skippedUsersCount,
|
||||||
|
totalUsers: activeMembers.length,
|
||||||
|
errors: syncErrors,
|
||||||
|
createdTeams: Array.from(createdTeams),
|
||||||
|
updatedTeams: Array.from(updatedTeams),
|
||||||
|
removedMemberships: totalRemovedMemberships,
|
||||||
|
syncDuration
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createGithubOrgSync,
|
createGithubOrgSync,
|
||||||
updateGithubOrgSync,
|
updateGithubOrgSync,
|
||||||
deleteGithubOrgSync,
|
deleteGithubOrgSync,
|
||||||
getGithubOrgSync,
|
getGithubOrgSync,
|
||||||
syncUserGroups
|
syncUserGroups,
|
||||||
|
syncAllTeams,
|
||||||
|
validateGithubToken
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,3 +21,24 @@ export interface TDeleteGithubOrgSyncDTO {
|
|||||||
export interface TGetGithubOrgSyncDTO {
|
export interface TGetGithubOrgSyncDTO {
|
||||||
orgPermission: OrgServiceActor;
|
orgPermission: OrgServiceActor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TSyncAllTeamsDTO {
|
||||||
|
orgPermission: OrgServiceActor;
|
||||||
|
githubOrgAccessToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TSyncResult {
|
||||||
|
syncedUsersCount: number;
|
||||||
|
skippedUsersCount: number;
|
||||||
|
totalUsers: number;
|
||||||
|
errors: string[];
|
||||||
|
createdTeams: string[];
|
||||||
|
updatedTeams: string[];
|
||||||
|
removedMemberships: number;
|
||||||
|
syncDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TValidateGithubTokenDTO {
|
||||||
|
orgPermission: OrgServiceActor;
|
||||||
|
githubOrgAccessToken: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -680,7 +680,9 @@ export const registerRoutes = async (
|
|||||||
kmsService,
|
kmsService,
|
||||||
permissionService,
|
permissionService,
|
||||||
groupDAL,
|
groupDAL,
|
||||||
userGroupMembershipDAL
|
userGroupMembershipDAL,
|
||||||
|
orgMembershipDAL,
|
||||||
|
identityMetadataDAL
|
||||||
});
|
});
|
||||||
|
|
||||||
const ldapService = ldapConfigServiceFactory({
|
const ldapService = ldapConfigServiceFactory({
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export {
|
export {
|
||||||
useCreateGithubSyncOrgConfig,
|
useCreateGithubSyncOrgConfig,
|
||||||
useDeleteGithubSyncOrgConfig,
|
useDeleteGithubSyncOrgConfig,
|
||||||
useUpdateGithubSyncOrgConfig
|
useSyncAllGithubTeams,
|
||||||
|
useUpdateGithubSyncOrgConfig,
|
||||||
|
useValidateGithubToken
|
||||||
} from "./mutations";
|
} from "./mutations";
|
||||||
export { githubOrgSyncConfigQueryKeys } from "./queries";
|
export { githubOrgSyncConfigQueryKeys } from "./queries";
|
||||||
|
|||||||
@@ -40,3 +40,51 @@ export const useDeleteGithubSyncOrgConfig = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useSyncAllGithubTeams = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
githubOrgAccessToken
|
||||||
|
}: {
|
||||||
|
githubOrgAccessToken?: string;
|
||||||
|
} = {}): Promise<{
|
||||||
|
syncedUsersCount: number;
|
||||||
|
skippedUsersCount: number;
|
||||||
|
totalUsers: number;
|
||||||
|
errors: string[];
|
||||||
|
createdTeams: string[];
|
||||||
|
updatedTeams: string[];
|
||||||
|
removedMemberships: number;
|
||||||
|
syncDuration: number;
|
||||||
|
}> => {
|
||||||
|
const response = await apiRequest.post("/api/v1/github-org-sync-config/sync-all-teams", {
|
||||||
|
githubOrgAccessToken
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useValidateGithubToken = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
githubOrgAccessToken
|
||||||
|
}: {
|
||||||
|
githubOrgAccessToken: string;
|
||||||
|
}): Promise<{
|
||||||
|
valid: boolean;
|
||||||
|
organizationInfo?: {
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
publicRepos?: number;
|
||||||
|
privateRepos?: number;
|
||||||
|
};
|
||||||
|
}> => {
|
||||||
|
const response = await apiRequest.post("/api/v1/github-org-sync-config/validate-token", {
|
||||||
|
githubOrgAccessToken
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
+274
-3
@@ -1,10 +1,26 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
import { OrgPermissionCan } from "@app/components/permissions";
|
import { OrgPermissionCan } from "@app/components/permissions";
|
||||||
import { Button, Modal, ModalContent, Skeleton, Spinner, Switch } from "@app/components/v2";
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
Skeleton,
|
||||||
|
Spinner,
|
||||||
|
Switch
|
||||||
|
} from "@app/components/v2";
|
||||||
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
|
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
|
||||||
import { githubOrgSyncConfigQueryKeys, useUpdateGithubSyncOrgConfig } from "@app/hooks/api";
|
import {
|
||||||
|
githubOrgSyncConfigQueryKeys,
|
||||||
|
useSyncAllGithubTeams,
|
||||||
|
useUpdateGithubSyncOrgConfig,
|
||||||
|
useValidateGithubToken
|
||||||
|
} from "@app/hooks/api";
|
||||||
import { usePopUp } from "@app/hooks/usePopUp";
|
import { usePopUp } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
import { GithubOrgSyncConfigModal } from "./GithubOrgSyncConfigModal";
|
import { GithubOrgSyncConfigModal } from "./GithubOrgSyncConfigModal";
|
||||||
@@ -14,9 +30,23 @@ export const OrgGithubSyncSection = () => {
|
|||||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||||
"upgradePlan",
|
"upgradePlan",
|
||||||
"githubOrgSyncConfig",
|
"githubOrgSyncConfig",
|
||||||
"deleteGithubOrgSyncConfig"
|
"deleteGithubOrgSyncConfig",
|
||||||
|
"syncAllTeamsToken"
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
|
const [accessToken, setAccessToken] = useState("");
|
||||||
|
const [isValidatingToken, setIsValidatingToken] = useState(false);
|
||||||
|
const [tokenValidationResult, setTokenValidationResult] = useState<{
|
||||||
|
valid: boolean;
|
||||||
|
organizationInfo?: {
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
publicRepos?: number;
|
||||||
|
privateRepos?: number;
|
||||||
|
};
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const githubOrgSyncConfig = useQuery({
|
const githubOrgSyncConfig = useQuery({
|
||||||
...githubOrgSyncConfigQueryKeys.get(),
|
...githubOrgSyncConfigQueryKeys.get(),
|
||||||
enabled: subscription.githubOrgSync,
|
enabled: subscription.githubOrgSync,
|
||||||
@@ -24,10 +54,133 @@ export const OrgGithubSyncSection = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateGithubSyncOrgConfig = useUpdateGithubSyncOrgConfig();
|
const updateGithubSyncOrgConfig = useUpdateGithubSyncOrgConfig();
|
||||||
|
const syncAllTeamsMutation = useSyncAllGithubTeams();
|
||||||
|
const validateGithubTokenMutation = useValidateGithubToken();
|
||||||
|
|
||||||
const isPending = subscription.githubOrgSync && githubOrgSyncConfig.isPending;
|
const isPending = subscription.githubOrgSync && githubOrgSyncConfig.isPending;
|
||||||
const data = !isPending && !githubOrgSyncConfig?.isError ? githubOrgSyncConfig?.data : undefined;
|
const data = !isPending && !githubOrgSyncConfig?.isError ? githubOrgSyncConfig?.data : undefined;
|
||||||
|
|
||||||
|
const handleBulkSync = async (token?: string) => {
|
||||||
|
try {
|
||||||
|
const result = await syncAllTeamsMutation.mutateAsync({
|
||||||
|
githubOrgAccessToken: token
|
||||||
|
});
|
||||||
|
let message = `Successfully synced teams for ${result.syncedUsersCount} out of ${result.totalUsers} users`;
|
||||||
|
|
||||||
|
const details = [];
|
||||||
|
if (result.createdTeams.length > 0) {
|
||||||
|
details.push(`${result.createdTeams.length} new teams created`);
|
||||||
|
}
|
||||||
|
if (result.updatedTeams.length > 0) {
|
||||||
|
details.push(`${result.updatedTeams.length} teams updated`);
|
||||||
|
}
|
||||||
|
if (result.removedMemberships > 0) {
|
||||||
|
details.push(`${result.removedMemberships} memberships removed`);
|
||||||
|
}
|
||||||
|
if (result.skippedUsersCount > 0) {
|
||||||
|
details.push(`${result.skippedUsersCount} users skipped`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.length > 0) {
|
||||||
|
message += `. ${details.join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: message,
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.errors && result.errors.length > 0) {
|
||||||
|
createNotification({
|
||||||
|
text: `Sync completed with ${result.errors.length} warnings. Check the console for details.`,
|
||||||
|
type: "warning"
|
||||||
|
});
|
||||||
|
console.warn("Sync errors:", result.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAccessToken("");
|
||||||
|
handlePopUpToggle("syncAllTeamsToken", false);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
(error as any)?.response?.data?.message || (error as Error)?.message || "Unknown error";
|
||||||
|
|
||||||
|
if (
|
||||||
|
errorMessage.includes("token") &&
|
||||||
|
(errorMessage.includes("required") ||
|
||||||
|
errorMessage.includes("invalid") ||
|
||||||
|
errorMessage.includes("expired"))
|
||||||
|
) {
|
||||||
|
handlePopUpOpen("syncAllTeamsToken");
|
||||||
|
createNotification({
|
||||||
|
text: errorMessage,
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
createNotification({
|
||||||
|
text: `Failed to sync GitHub teams: ${errorMessage}`,
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateToken = async () => {
|
||||||
|
if (!accessToken.trim()) {
|
||||||
|
createNotification({
|
||||||
|
text: "Please enter a GitHub access token",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsValidatingToken(true);
|
||||||
|
try {
|
||||||
|
const result = await validateGithubTokenMutation.mutateAsync({
|
||||||
|
githubOrgAccessToken: accessToken.trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
setTokenValidationResult(result);
|
||||||
|
|
||||||
|
if (result.valid && result.organizationInfo) {
|
||||||
|
createNotification({
|
||||||
|
text: `Token validated successfully for organization: ${result.organizationInfo.name}`,
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
(error as any)?.response?.data?.message ||
|
||||||
|
(error as Error)?.message ||
|
||||||
|
"Token validation failed";
|
||||||
|
createNotification({
|
||||||
|
text: errorMessage,
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
setTokenValidationResult({ valid: false });
|
||||||
|
} finally {
|
||||||
|
setIsValidatingToken(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSyncWithToken = async () => {
|
||||||
|
if (!accessToken.trim()) {
|
||||||
|
createNotification({
|
||||||
|
text: "Please enter a GitHub access token",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tokenValidationResult?.valid) {
|
||||||
|
await validateToken();
|
||||||
|
if (!tokenValidationResult?.valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleBulkSync(accessToken.trim());
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
|
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
|
||||||
<p className="text-xl font-semibold text-gray-200">
|
<p className="text-xl font-semibold text-gray-200">
|
||||||
@@ -86,6 +239,30 @@ export const OrgGithubSyncSection = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{data && data.isActive && (
|
||||||
|
<div className="py-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h2 className="text-md text-mineshaft-100">Sync Now</h2>
|
||||||
|
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.GithubOrgSync}>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleBulkSync()}
|
||||||
|
colorSchema="primary"
|
||||||
|
variant="outline_bg"
|
||||||
|
isDisabled={!isAllowed || syncAllTeamsMutation.isPending}
|
||||||
|
isLoading={syncAllTeamsMutation.isPending}
|
||||||
|
>
|
||||||
|
Sync Now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-mineshaft-300">
|
||||||
|
Manually sync GitHub teams for all organization members. This will update team
|
||||||
|
memberships for users who have previously logged in with GitHub.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={popUp?.githubOrgSyncConfig?.isOpen}
|
isOpen={popUp?.githubOrgSyncConfig?.isOpen}
|
||||||
onOpenChange={(isOpen) => {
|
onOpenChange={(isOpen) => {
|
||||||
@@ -109,6 +286,100 @@ export const OrgGithubSyncSection = () => {
|
|||||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||||
text="You can use GitHub Organization Plan if you switch to Infisical's Enterprise plan."
|
text="You can use GitHub Organization Plan if you switch to Infisical's Enterprise plan."
|
||||||
/>
|
/>
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp?.syncAllTeamsToken?.isOpen}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
handlePopUpToggle("syncAllTeamsToken", isOpen);
|
||||||
|
if (!isOpen) {
|
||||||
|
setAccessToken("");
|
||||||
|
setTokenValidationResult(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalContent
|
||||||
|
title="GitHub Access Token Required"
|
||||||
|
subTitle="Provide a GitHub access token with organization and team access permissions"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormControl label="GitHub Access Token">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="ghp_xxxxxxxxxxxx"
|
||||||
|
value={accessToken}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAccessToken(e.target.value);
|
||||||
|
if (tokenValidationResult) {
|
||||||
|
setTokenValidationResult(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{tokenValidationResult && (
|
||||||
|
<div
|
||||||
|
className={`rounded p-2 text-sm ${tokenValidationResult.valid ? "border border-green-800 bg-green-900/20 text-green-400" : "border border-red-800 bg-red-900/20 text-red-400"}`}
|
||||||
|
>
|
||||||
|
{tokenValidationResult.valid && tokenValidationResult.organizationInfo ? (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">✓ Token Valid</div>
|
||||||
|
<div>
|
||||||
|
Organization: {tokenValidationResult.organizationInfo.name} (
|
||||||
|
{tokenValidationResult.organizationInfo.login})
|
||||||
|
</div>
|
||||||
|
{tokenValidationResult.organizationInfo.publicRepos !== undefined && (
|
||||||
|
<div>
|
||||||
|
Public repos: {tokenValidationResult.organizationInfo.publicRepos}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="font-medium">✗ Token Invalid</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<p className="text-sm text-mineshaft-400">
|
||||||
|
The token needs <code>read:org</code> and <code>read:user</code> permissions to access
|
||||||
|
organization teams and members. Once provided and verified, the token will be securely
|
||||||
|
stored for future syncs.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Button
|
||||||
|
colorSchema="secondary"
|
||||||
|
variant="outline_bg"
|
||||||
|
onClick={validateToken}
|
||||||
|
isLoading={isValidatingToken}
|
||||||
|
isDisabled={
|
||||||
|
!accessToken.trim() || isValidatingToken || syncAllTeamsMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Validate Token
|
||||||
|
</Button>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button
|
||||||
|
colorSchema="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
handlePopUpToggle("syncAllTeamsToken", false);
|
||||||
|
setAccessToken("");
|
||||||
|
setTokenValidationResult(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
colorSchema="primary"
|
||||||
|
onClick={handleSyncWithToken}
|
||||||
|
isLoading={syncAllTeamsMutation.isPending}
|
||||||
|
isDisabled={!accessToken.trim() || syncAllTeamsMutation.isPending}
|
||||||
|
>
|
||||||
|
Sync Teams
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user