mirror of
https://github.com/Infisical/infisical.git
synced 2025-08-02 08:27:38 +00:00
Compare commits
40 Commits
daniel/k8s
...
fix/cliCus
Author | SHA1 | Date | |
---|---|---|---|
|
1e4ca2f48f | ||
|
b0d5be6221 | ||
|
f0a45fb7d8 | ||
|
40398efb06 | ||
|
a16c1336fc | ||
|
ef4df9691d | ||
|
6a23583391 | ||
|
e0322c8a7f | ||
|
2e8003ca95 | ||
|
d185dbb7ff | ||
|
afcae17e91 | ||
|
6cd7657e41 | ||
|
38bf5e8b1d | ||
|
4292cb2a04 | ||
|
051f53c66e | ||
|
a6bafb8adc | ||
|
99daa43fc6 | ||
|
27badad3d7 | ||
|
b5e3af6e7d | ||
|
280fbdfbb9 | ||
|
18fc10aaec | ||
|
b20e04bdeb | ||
|
4abdd4216b | ||
|
332ed68c13 | ||
|
d7a99db66a | ||
|
fc0bdc25af | ||
|
5ffe45eaf5 | ||
|
8f795100ea | ||
|
e1dee0678e | ||
|
8b25f202fe | ||
|
33ce783fda | ||
|
63c48dc095 | ||
|
52f8c6adba | ||
|
3d2b2cbbab | ||
|
1a82809bd5 | ||
|
c4f994750d | ||
|
fa7020949c | ||
|
eca2b3ccde | ||
|
67fc16ecd3 | ||
|
f85add7cca |
@@ -0,0 +1,47 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasEmail = await knex.schema.hasColumn(TableName.Users, "email");
|
||||
const hasUsername = await knex.schema.hasColumn(TableName.Users, "username");
|
||||
if (hasEmail) {
|
||||
await knex(TableName.Users)
|
||||
.where({ isGhost: false })
|
||||
.update({
|
||||
// @ts-expect-error email assume string this is expected
|
||||
email: knex.raw("lower(email)")
|
||||
});
|
||||
}
|
||||
if (hasUsername) {
|
||||
await knex.schema.raw(`
|
||||
CREATE INDEX IF NOT EXISTS ${TableName.Users}_lower_username_idx
|
||||
ON ${TableName.Users} (LOWER(username))
|
||||
`);
|
||||
|
||||
const duplicatesSubquery = knex(TableName.Users)
|
||||
.select(knex.raw("lower(username) as lowercase_username"))
|
||||
.groupBy("lowercase_username")
|
||||
.having(knex.raw("count(*)"), ">", 1);
|
||||
|
||||
// Update usernames to lowercase where they won't create duplicates
|
||||
await knex(TableName.Users)
|
||||
.where({ isGhost: false })
|
||||
.whereRaw("username <> lower(username)") // Only update if not already lowercase
|
||||
// @ts-expect-error username assume string this is expected
|
||||
.whereNotIn(knex.raw("lower(username)"), duplicatesSubquery)
|
||||
.update({
|
||||
// @ts-expect-error username assume string this is expected
|
||||
username: knex.raw("lower(username)")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasUsername = await knex.schema.hasColumn(TableName.Users, "username");
|
||||
if (hasUsername) {
|
||||
await knex.schema.raw(`
|
||||
DROP INDEX IF EXISTS ${TableName.Users}_lower_username_idx
|
||||
`);
|
||||
}
|
||||
}
|
@@ -0,0 +1,22 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
await knex.schema.alterTable(TableName.SecretSync, (t) => {
|
||||
t.string("name", 64).notNullable().alter();
|
||||
});
|
||||
await knex.schema.alterTable(TableName.ProjectTemplates, (t) => {
|
||||
t.string("name", 64).notNullable().alter();
|
||||
});
|
||||
await knex.schema.alterTable(TableName.AppConnection, (t) => {
|
||||
t.string("name", 64).notNullable().alter();
|
||||
});
|
||||
await knex.schema.alterTable(TableName.SecretRotationV2, (t) => {
|
||||
t.string("name", 64).notNullable().alter();
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// No down migration or it will error
|
||||
}
|
@@ -145,7 +145,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
|
||||
|
||||
const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({
|
||||
externalId: profile.nameID,
|
||||
email,
|
||||
email: email.toLowerCase(),
|
||||
firstName,
|
||||
lastName: lastName as string,
|
||||
relayState: (req.body as { RelayState?: string }).RelayState,
|
||||
|
@@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { ProjectPermissionApprovalActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
|
||||
@@ -98,7 +98,7 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionApprovalActions.Create,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
const env = await projectEnvDAL.findOne({ slug: environment, projectId: project.id });
|
||||
@@ -256,7 +256,10 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionApprovalActions.Edit,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
|
||||
const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => {
|
||||
const doc = await accessApprovalPolicyDAL.updateById(
|
||||
@@ -341,7 +344,7 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Delete,
|
||||
ProjectPermissionApprovalActions.Delete,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
|
||||
@@ -432,7 +435,10 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionApprovalActions.Read,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
|
||||
return policy;
|
||||
};
|
||||
|
@@ -111,9 +111,9 @@ export const groupDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
|
||||
if (search) {
|
||||
void query.andWhereRaw(`CONCAT_WS(' ', "firstName", "lastName", "username") ilike ?`, [`%${search}%`]);
|
||||
void query.andWhereRaw(`CONCAT_WS(' ', "firstName", "lastName", lower("username")) ilike ?`, [`%${search}%`]);
|
||||
} else if (username) {
|
||||
void query.andWhere(`${TableName.Users}.username`, "ilike", `%${username}%`);
|
||||
void query.andWhereRaw(`lower("${TableName.Users}"."username") ilike ?`, `%${username}%`);
|
||||
}
|
||||
|
||||
switch (filter) {
|
||||
|
@@ -30,7 +30,7 @@ import {
|
||||
import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal";
|
||||
|
||||
type TGroupServiceFactoryDep = {
|
||||
userDAL: Pick<TUserDALFactory, "find" | "findUserEncKeyByUserIdsBatch" | "transaction" | "findOne">;
|
||||
userDAL: Pick<TUserDALFactory, "find" | "findUserEncKeyByUserIdsBatch" | "transaction" | "findUserByUsername">;
|
||||
groupDAL: Pick<
|
||||
TGroupDALFactory,
|
||||
"create" | "findOne" | "update" | "delete" | "findAllGroupPossibleMembers" | "findById" | "transaction"
|
||||
@@ -380,7 +380,10 @@ export const groupServiceFactory = ({
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const user = await userDAL.findOne({ username });
|
||||
const usersWithUsername = await userDAL.findUserByUsername(username);
|
||||
// akhilmhdh: case sensitive email resolution
|
||||
const user =
|
||||
usersWithUsername?.length > 1 ? usersWithUsername.find((el) => el.username === username) : usersWithUsername?.[0];
|
||||
if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` });
|
||||
|
||||
const users = await addUsersToGroupByUserIds({
|
||||
@@ -461,7 +464,10 @@ export const groupServiceFactory = ({
|
||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
||||
});
|
||||
|
||||
const user = await userDAL.findOne({ username });
|
||||
const usersWithUsername = await userDAL.findUserByUsername(username);
|
||||
// akhilmhdh: case sensitive email resolution
|
||||
const user =
|
||||
usersWithUsername?.length > 1 ? usersWithUsername.find((el) => el.username === username) : usersWithUsername?.[0];
|
||||
if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` });
|
||||
|
||||
const users = await removeUsersFromGroupByUserIds({
|
||||
|
@@ -380,7 +380,7 @@ export const ldapConfigServiceFactory = ({
|
||||
if (serverCfg.trustLdapEmails) {
|
||||
newUser = await userDAL.findOne(
|
||||
{
|
||||
email,
|
||||
email: email.toLowerCase(),
|
||||
isEmailVerified: true
|
||||
},
|
||||
tx
|
||||
@@ -391,8 +391,8 @@ export const ldapConfigServiceFactory = ({
|
||||
const uniqueUsername = await normalizeUsername(username, userDAL);
|
||||
newUser = await userDAL.create(
|
||||
{
|
||||
username: serverCfg.trustLdapEmails ? email : uniqueUsername,
|
||||
email,
|
||||
username: serverCfg.trustLdapEmails ? email.toLowerCase() : uniqueUsername,
|
||||
email: email.toLowerCase(),
|
||||
isEmailVerified: serverCfg.trustLdapEmails,
|
||||
firstName,
|
||||
lastName,
|
||||
@@ -429,7 +429,7 @@ export const ldapConfigServiceFactory = ({
|
||||
await orgMembershipDAL.create(
|
||||
{
|
||||
userId: newUser.id,
|
||||
inviteEmail: email,
|
||||
inviteEmail: email.toLowerCase(),
|
||||
orgId,
|
||||
role,
|
||||
roleId,
|
||||
|
@@ -2,6 +2,7 @@ import axios, { AxiosError } from "axios";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
import { TFeatureSet } from "./license-types";
|
||||
|
||||
@@ -98,9 +99,10 @@ export const setupLicenseRequestWithStore = (baseURL: string, refreshUrl: string
|
||||
(response) => response,
|
||||
async (err) => {
|
||||
const originalRequest = (err as AxiosError).config;
|
||||
|
||||
const errStatusCode = Number((err as AxiosError)?.response?.status);
|
||||
logger.error((err as AxiosError)?.response?.data, "License server call error");
|
||||
// eslint-disable-next-line
|
||||
if ((err as AxiosError)?.response?.status === 401 && !(originalRequest as any)._retry) {
|
||||
if ((errStatusCode === 401 || errStatusCode === 403) && !(originalRequest as any)._retry) {
|
||||
// eslint-disable-next-line
|
||||
(originalRequest as any)._retry = true; // injected
|
||||
|
||||
|
@@ -348,8 +348,8 @@ export const licenseServiceFactory = ({
|
||||
} = await licenseServerCloudApi.request.post(
|
||||
`/api/license-server/v1/customers/${organization.customerId}/billing-details/payment-methods`,
|
||||
{
|
||||
success_url: `${appCfg.SITE_URL}/dashboard`,
|
||||
cancel_url: `${appCfg.SITE_URL}/dashboard`
|
||||
success_url: `${appCfg.SITE_URL}/organization/billing`,
|
||||
cancel_url: `${appCfg.SITE_URL}/organization/billing`
|
||||
}
|
||||
);
|
||||
|
||||
@@ -362,7 +362,7 @@ export const licenseServiceFactory = ({
|
||||
} = await licenseServerCloudApi.request.post(
|
||||
`/api/license-server/v1/customers/${organization.customerId}/billing-details/billing-portal`,
|
||||
{
|
||||
return_url: `${appCfg.SITE_URL}/dashboard`
|
||||
return_url: `${appCfg.SITE_URL}/organization/billing`
|
||||
}
|
||||
);
|
||||
|
||||
@@ -379,7 +379,7 @@ export const licenseServiceFactory = ({
|
||||
message: `Organization with ID '${orgId}' not found`
|
||||
});
|
||||
}
|
||||
if (instanceType !== InstanceType.OnPrem && instanceType !== InstanceType.EnterpriseOnPremOffline) {
|
||||
if (instanceType === InstanceType.Cloud) {
|
||||
const { data } = await licenseServerCloudApi.request.get(
|
||||
`/api/license-server/v1/customers/${organization.customerId}/cloud-plan/billing`
|
||||
);
|
||||
@@ -407,11 +407,38 @@ export const licenseServiceFactory = ({
|
||||
message: `Organization with ID '${orgId}' not found`
|
||||
});
|
||||
}
|
||||
if (instanceType !== InstanceType.OnPrem && instanceType !== InstanceType.EnterpriseOnPremOffline) {
|
||||
const { data } = await licenseServerCloudApi.request.get(
|
||||
`/api/license-server/v1/customers/${organization.customerId}/cloud-plan/table`
|
||||
);
|
||||
return data;
|
||||
|
||||
const orgMembersUsed = await orgDAL.countAllOrgMembers(orgId);
|
||||
const identityUsed = await identityOrgMembershipDAL.countAllOrgIdentities({ orgId });
|
||||
const projects = await projectDAL.find({ orgId });
|
||||
const projectCount = projects.length;
|
||||
|
||||
if (instanceType === InstanceType.Cloud) {
|
||||
const { data } = await licenseServerCloudApi.request.get<{
|
||||
head: { name: string }[];
|
||||
rows: { name: string; allowed: boolean }[];
|
||||
}>(`/api/license-server/v1/customers/${organization.customerId}/cloud-plan/table`);
|
||||
|
||||
const formattedData = {
|
||||
head: data.head,
|
||||
rows: data.rows.map((el) => {
|
||||
let used = "-";
|
||||
|
||||
if (el.name === BillingPlanRows.MemberLimit.name) {
|
||||
used = orgMembersUsed.toString();
|
||||
} else if (el.name === BillingPlanRows.WorkspaceLimit.name) {
|
||||
used = projectCount.toString();
|
||||
} else if (el.name === BillingPlanRows.IdentityLimit.name) {
|
||||
used = (identityUsed + orgMembersUsed).toString();
|
||||
}
|
||||
|
||||
return {
|
||||
...el,
|
||||
used
|
||||
};
|
||||
})
|
||||
};
|
||||
return formattedData;
|
||||
}
|
||||
|
||||
const mappedRows = await Promise.all(
|
||||
@@ -420,14 +447,11 @@ export const licenseServiceFactory = ({
|
||||
let used = "-";
|
||||
|
||||
if (field === BillingPlanRows.MemberLimit.field) {
|
||||
const orgMemberships = await orgDAL.countAllOrgMembers(orgId);
|
||||
used = orgMemberships.toString();
|
||||
used = orgMembersUsed.toString();
|
||||
} else if (field === BillingPlanRows.WorkspaceLimit.field) {
|
||||
const projects = await projectDAL.find({ orgId });
|
||||
used = projects.length.toString();
|
||||
used = projectCount.toString();
|
||||
} else if (field === BillingPlanRows.IdentityLimit.field) {
|
||||
const identities = await identityOrgMembershipDAL.countAllOrgIdentities({ orgId });
|
||||
used = identities.toString();
|
||||
used = identityUsed.toString();
|
||||
}
|
||||
|
||||
return {
|
||||
|
@@ -171,8 +171,8 @@ export const oidcConfigServiceFactory = ({
|
||||
};
|
||||
|
||||
const oidcLogin = async ({
|
||||
externalId,
|
||||
email,
|
||||
externalId,
|
||||
firstName,
|
||||
lastName,
|
||||
orgId,
|
||||
@@ -717,7 +717,7 @@ export const oidcConfigServiceFactory = ({
|
||||
const groups = typeof claims.groups === "string" ? [claims.groups] : (claims.groups as string[] | undefined);
|
||||
|
||||
oidcLogin({
|
||||
email: claims.email,
|
||||
email: claims.email.toLowerCase(),
|
||||
externalId: claims.sub,
|
||||
firstName: claims.given_name ?? "",
|
||||
lastName: claims.family_name ?? "",
|
||||
|
@@ -2,6 +2,7 @@ import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability"
|
||||
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionApprovalActions,
|
||||
ProjectPermissionCertificateActions,
|
||||
ProjectPermissionCmekActions,
|
||||
ProjectPermissionDynamicSecretActions,
|
||||
@@ -25,7 +26,6 @@ const buildAdminPermissionRules = () => {
|
||||
[
|
||||
ProjectPermissionSub.SecretFolders,
|
||||
ProjectPermissionSub.SecretImports,
|
||||
ProjectPermissionSub.SecretApproval,
|
||||
ProjectPermissionSub.Role,
|
||||
ProjectPermissionSub.Integrations,
|
||||
ProjectPermissionSub.Webhooks,
|
||||
@@ -55,6 +55,17 @@ const buildAdminPermissionRules = () => {
|
||||
);
|
||||
});
|
||||
|
||||
can(
|
||||
[
|
||||
ProjectPermissionApprovalActions.Read,
|
||||
ProjectPermissionApprovalActions.Edit,
|
||||
ProjectPermissionApprovalActions.Create,
|
||||
ProjectPermissionApprovalActions.Delete,
|
||||
ProjectPermissionApprovalActions.AllowChangeBypass
|
||||
],
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
|
||||
can(
|
||||
[
|
||||
ProjectPermissionCertificateActions.Read,
|
||||
@@ -243,7 +254,7 @@ const buildMemberPermissionRules = () => {
|
||||
ProjectPermissionSub.SecretImports
|
||||
);
|
||||
|
||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretApproval);
|
||||
can([ProjectPermissionApprovalActions.Read], ProjectPermissionSub.SecretApproval);
|
||||
can([ProjectPermissionSecretRotationActions.Read], ProjectPermissionSub.SecretRotation);
|
||||
|
||||
can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback);
|
||||
@@ -391,7 +402,7 @@ const buildViewerPermissionRules = () => {
|
||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretFolders);
|
||||
can(ProjectPermissionDynamicSecretActions.ReadRootCredential, ProjectPermissionSub.DynamicSecrets);
|
||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretImports);
|
||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval);
|
||||
can(ProjectPermissionApprovalActions.Read, ProjectPermissionSub.SecretApproval);
|
||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback);
|
||||
can(ProjectPermissionSecretRotationActions.Read, ProjectPermissionSub.SecretRotation);
|
||||
can(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member);
|
||||
|
@@ -34,6 +34,14 @@ export enum ProjectPermissionSecretActions {
|
||||
Delete = "delete"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionApprovalActions {
|
||||
Read = "read",
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
Delete = "delete",
|
||||
AllowChangeBypass = "allow-change-bypass"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionCmekActions {
|
||||
Read = "read",
|
||||
Create = "create",
|
||||
@@ -242,7 +250,7 @@ export type ProjectPermissionSet =
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.IpAllowList]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Settings]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
|
||||
| [ProjectPermissionApprovalActions, ProjectPermissionSub.SecretApproval]
|
||||
| [
|
||||
ProjectPermissionSecretRotationActions,
|
||||
(
|
||||
@@ -439,7 +447,7 @@ const PkiSubscriberConditionSchema = z
|
||||
const GeneralPermissionSchema = [
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionApprovalActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
}),
|
||||
@@ -605,7 +613,7 @@ const GeneralPermissionSchema = [
|
||||
})
|
||||
];
|
||||
|
||||
// Do not update this schema anymore, as it's kept purely for backwards compatability. Update V2 schema only.
|
||||
// Do not update this schema anymore, as it's kept purely for backwards compatibility. Update V2 schema only.
|
||||
export const ProjectPermissionV1Schema = z.discriminatedUnion("subject", [
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.Secrets).describe("The entity this permission pertains to."),
|
||||
|
@@ -342,7 +342,7 @@ export const scimServiceFactory = ({
|
||||
orgMembership = await orgMembershipDAL.create(
|
||||
{
|
||||
userId: userAlias.userId,
|
||||
inviteEmail: email,
|
||||
inviteEmail: email.toLowerCase(),
|
||||
orgId,
|
||||
role,
|
||||
roleId,
|
||||
@@ -364,7 +364,7 @@ export const scimServiceFactory = ({
|
||||
if (trustScimEmails) {
|
||||
user = await userDAL.findOne(
|
||||
{
|
||||
email,
|
||||
email: email.toLowerCase(),
|
||||
isEmailVerified: true
|
||||
},
|
||||
tx
|
||||
@@ -379,8 +379,8 @@ export const scimServiceFactory = ({
|
||||
);
|
||||
user = await userDAL.create(
|
||||
{
|
||||
username: trustScimEmails ? email : uniqueUsername,
|
||||
email,
|
||||
username: trustScimEmails ? email.toLowerCase() : uniqueUsername,
|
||||
email: email.toLowerCase(),
|
||||
isEmailVerified: trustScimEmails,
|
||||
firstName,
|
||||
lastName,
|
||||
@@ -396,7 +396,7 @@ export const scimServiceFactory = ({
|
||||
userId: user.id,
|
||||
aliasType,
|
||||
externalId,
|
||||
emails: email ? [email] : [],
|
||||
emails: email ? [email.toLowerCase()] : [],
|
||||
orgId
|
||||
},
|
||||
tx
|
||||
@@ -418,7 +418,7 @@ export const scimServiceFactory = ({
|
||||
orgMembership = await orgMembershipDAL.create(
|
||||
{
|
||||
userId: user.id,
|
||||
inviteEmail: email,
|
||||
inviteEmail: email.toLowerCase(),
|
||||
orgId,
|
||||
role,
|
||||
roleId,
|
||||
@@ -529,7 +529,7 @@ export const scimServiceFactory = ({
|
||||
membership.userId,
|
||||
{
|
||||
firstName: scimUser.name.givenName,
|
||||
email: scimUser.emails[0].value,
|
||||
email: scimUser.emails[0].value.toLowerCase(),
|
||||
lastName: scimUser.name.familyName,
|
||||
isEmailVerified: hasEmailChanged ? trustScimEmails : undefined
|
||||
},
|
||||
@@ -606,7 +606,7 @@ export const scimServiceFactory = ({
|
||||
membership.userId,
|
||||
{
|
||||
firstName,
|
||||
email,
|
||||
email: email?.toLowerCase(),
|
||||
lastName,
|
||||
isEmailVerified:
|
||||
org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails
|
||||
|
@@ -3,7 +3,7 @@ import picomatch from "picomatch";
|
||||
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { ProjectPermissionApprovalActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { removeTrailingSlash } from "@app/lib/fn";
|
||||
import { containsGlobPatterns } from "@app/lib/picomatch";
|
||||
@@ -89,7 +89,7 @@ export const secretApprovalPolicyServiceFactory = ({
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
ProjectPermissionApprovalActions.Create,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
|
||||
@@ -204,7 +204,10 @@ export const secretApprovalPolicyServiceFactory = ({
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionApprovalActions.Edit,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
|
||||
const plan = await licenseService.getPlan(actorOrgId);
|
||||
if (!plan.secretApproval) {
|
||||
@@ -301,7 +304,7 @@ export const secretApprovalPolicyServiceFactory = ({
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Delete,
|
||||
ProjectPermissionApprovalActions.Delete,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
|
||||
@@ -340,7 +343,10 @@ export const secretApprovalPolicyServiceFactory = ({
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionApprovalActions.Read,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
|
||||
const sapPolicies = await secretApprovalPolicyDAL.find({ projectId, deletedAt: null });
|
||||
return sapPolicies;
|
||||
@@ -413,7 +419,10 @@ export const secretApprovalPolicyServiceFactory = ({
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionApprovalActions.Read,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
|
||||
return sapPolicy;
|
||||
};
|
||||
|
@@ -62,7 +62,11 @@ import { TUserDALFactory } from "@app/services/user/user-dal";
|
||||
import { TLicenseServiceFactory } from "../license/license-service";
|
||||
import { throwIfMissingSecretReadValueOrDescribePermission } from "../permission/permission-fns";
|
||||
import { TPermissionServiceFactory } from "../permission/permission-service";
|
||||
import { ProjectPermissionSecretActions, ProjectPermissionSub } from "../permission/project-permission";
|
||||
import {
|
||||
ProjectPermissionApprovalActions,
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSub
|
||||
} from "../permission/project-permission";
|
||||
import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal";
|
||||
import { TSecretSnapshotServiceFactory } from "../secret-snapshot/secret-snapshot-service";
|
||||
import { TSecretApprovalRequestDALFactory } from "./secret-approval-request-dal";
|
||||
@@ -504,7 +508,7 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
const { hasRole } = await permissionService.getProjectPermission({
|
||||
const { hasRole, permission } = await permissionService.getProjectPermission({
|
||||
actor: ActorType.USER,
|
||||
actorId,
|
||||
projectId,
|
||||
@@ -531,7 +535,13 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
).length;
|
||||
const isSoftEnforcement = secretApprovalRequest.policy.enforcementLevel === EnforcementLevel.Soft;
|
||||
|
||||
if (!hasMinApproval && !isSoftEnforcement)
|
||||
if (
|
||||
!hasMinApproval &&
|
||||
!(
|
||||
isSoftEnforcement &&
|
||||
permission.can(ProjectPermissionApprovalActions.AllowChangeBypass, ProjectPermissionSub.SecretApproval)
|
||||
)
|
||||
)
|
||||
throw new BadRequestError({ message: "Doesn't have minimum approvals needed" });
|
||||
|
||||
const { botKey, shouldUseSecretV2Bridge, project } = await projectBotService.getBotKey(projectId);
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import ldap from "ldapjs";
|
||||
import ldap, { Client, SearchOptions } from "ldapjs";
|
||||
|
||||
import {
|
||||
TRotationFactory,
|
||||
@@ -8,26 +8,73 @@ import {
|
||||
TRotationFactoryRotateCredentials
|
||||
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { DistinguishedNameRegex } from "@app/lib/regex";
|
||||
import { encryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
|
||||
import { getLdapConnectionClient, LdapProvider, TLdapConnection } from "@app/services/app-connection/ldap";
|
||||
|
||||
import { generatePassword } from "../shared/utils";
|
||||
import {
|
||||
LdapPasswordRotationMethod,
|
||||
TLdapPasswordRotationGeneratedCredentials,
|
||||
TLdapPasswordRotationInput,
|
||||
TLdapPasswordRotationWithConnection
|
||||
} from "./ldap-password-rotation-types";
|
||||
|
||||
const getEncodedPassword = (password: string) => Buffer.from(`"${password}"`, "utf16le");
|
||||
|
||||
const getDN = async (dn: string, client: Client): Promise<string> => {
|
||||
if (DistinguishedNameRegex.test(dn)) return dn;
|
||||
|
||||
const opts: SearchOptions = {
|
||||
filter: `(userPrincipalName=${dn})`,
|
||||
scope: "sub",
|
||||
attributes: ["dn"]
|
||||
};
|
||||
|
||||
const base = dn
|
||||
.split("@")[1]
|
||||
.split(".")
|
||||
.map((dc) => `dc=${dc}`)
|
||||
.join(",");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Perform the search
|
||||
client.search(base, opts, (err, res) => {
|
||||
if (err) {
|
||||
logger.error(err, "LDAP Failed to get DN");
|
||||
reject(new Error(`Provider Resolve DN Error: ${err.message}`));
|
||||
}
|
||||
|
||||
let userDn: string | null;
|
||||
|
||||
res.on("searchEntry", (entry) => {
|
||||
userDn = entry.objectName;
|
||||
});
|
||||
|
||||
res.on("error", (error) => {
|
||||
logger.error(error, "LDAP Failed to get DN");
|
||||
reject(new Error(`Provider Resolve DN Error: ${error.message}`));
|
||||
});
|
||||
|
||||
res.on("end", () => {
|
||||
if (userDn) {
|
||||
resolve(userDn);
|
||||
} else {
|
||||
reject(new Error(`Unable to resolve DN for ${dn}.`));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const ldapPasswordRotationFactory: TRotationFactory<
|
||||
TLdapPasswordRotationWithConnection,
|
||||
TLdapPasswordRotationGeneratedCredentials
|
||||
TLdapPasswordRotationGeneratedCredentials,
|
||||
TLdapPasswordRotationInput["temporaryParameters"]
|
||||
> = (secretRotation, appConnectionDAL, kmsService) => {
|
||||
const {
|
||||
connection,
|
||||
parameters: { dn, passwordRequirements },
|
||||
secretsMapping
|
||||
} = secretRotation;
|
||||
const { connection, parameters, secretsMapping, activeIndex } = secretRotation;
|
||||
|
||||
const { dn, passwordRequirements } = parameters;
|
||||
|
||||
const $verifyCredentials = async (credentials: Pick<TLdapConnection["credentials"], "dn" | "password">) => {
|
||||
try {
|
||||
@@ -40,13 +87,21 @@ export const ldapPasswordRotationFactory: TRotationFactory<
|
||||
}
|
||||
};
|
||||
|
||||
const $rotatePassword = async () => {
|
||||
const $rotatePassword = async (currentPassword?: string) => {
|
||||
const { credentials, orgId } = connection;
|
||||
|
||||
if (!credentials.url.startsWith("ldaps")) throw new Error("Password Rotation requires an LDAPS connection");
|
||||
|
||||
const client = await getLdapConnectionClient(credentials);
|
||||
const isPersonalRotation = credentials.dn === dn;
|
||||
const client = await getLdapConnectionClient(
|
||||
currentPassword
|
||||
? {
|
||||
...credentials,
|
||||
password: currentPassword,
|
||||
dn
|
||||
}
|
||||
: credentials
|
||||
);
|
||||
const isConnectionRotation = credentials.dn === dn;
|
||||
|
||||
const password = generatePassword(passwordRequirements);
|
||||
|
||||
@@ -58,8 +113,8 @@ export const ldapPasswordRotationFactory: TRotationFactory<
|
||||
const encodedPassword = getEncodedPassword(password);
|
||||
|
||||
// service account vs personal password rotation require different changes
|
||||
if (isPersonalRotation) {
|
||||
const currentEncodedPassword = getEncodedPassword(credentials.password);
|
||||
if (isConnectionRotation || currentPassword) {
|
||||
const currentEncodedPassword = getEncodedPassword(currentPassword || credentials.password);
|
||||
|
||||
changes = [
|
||||
new ldap.Change({
|
||||
@@ -93,8 +148,9 @@ export const ldapPasswordRotationFactory: TRotationFactory<
|
||||
}
|
||||
|
||||
try {
|
||||
const userDn = await getDN(dn, client);
|
||||
await new Promise((resolve, reject) => {
|
||||
client.modify(dn, changes, (err) => {
|
||||
client.modify(userDn, changes, (err) => {
|
||||
if (err) {
|
||||
logger.error(err, "LDAP Password Rotation Failed");
|
||||
reject(new Error(`Provider Modify Error: ${err.message}`));
|
||||
@@ -110,7 +166,7 @@ export const ldapPasswordRotationFactory: TRotationFactory<
|
||||
|
||||
await $verifyCredentials({ dn, password });
|
||||
|
||||
if (isPersonalRotation) {
|
||||
if (isConnectionRotation) {
|
||||
const updatedCredentials: TLdapConnection["credentials"] = {
|
||||
...credentials,
|
||||
password
|
||||
@@ -128,29 +184,41 @@ export const ldapPasswordRotationFactory: TRotationFactory<
|
||||
return { dn, password };
|
||||
};
|
||||
|
||||
const issueCredentials: TRotationFactoryIssueCredentials<TLdapPasswordRotationGeneratedCredentials> = async (
|
||||
callback
|
||||
) => {
|
||||
const credentials = await $rotatePassword();
|
||||
const issueCredentials: TRotationFactoryIssueCredentials<
|
||||
TLdapPasswordRotationGeneratedCredentials,
|
||||
TLdapPasswordRotationInput["temporaryParameters"]
|
||||
> = async (callback, temporaryParameters) => {
|
||||
const credentials = await $rotatePassword(
|
||||
parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal
|
||||
? temporaryParameters?.password
|
||||
: undefined
|
||||
);
|
||||
|
||||
return callback(credentials);
|
||||
};
|
||||
|
||||
const revokeCredentials: TRotationFactoryRevokeCredentials<TLdapPasswordRotationGeneratedCredentials> = async (
|
||||
_,
|
||||
credentialsToRevoke,
|
||||
callback
|
||||
) => {
|
||||
const currentPassword = credentialsToRevoke[activeIndex].password;
|
||||
|
||||
// we just rotate to a new password, essentially revoking old credentials
|
||||
await $rotatePassword();
|
||||
await $rotatePassword(
|
||||
parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal ? currentPassword : undefined
|
||||
);
|
||||
|
||||
return callback();
|
||||
};
|
||||
|
||||
const rotateCredentials: TRotationFactoryRotateCredentials<TLdapPasswordRotationGeneratedCredentials> = async (
|
||||
_,
|
||||
callback
|
||||
callback,
|
||||
activeCredentials
|
||||
) => {
|
||||
const credentials = await $rotatePassword();
|
||||
const credentials = await $rotatePassword(
|
||||
parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal ? activeCredentials.password : undefined
|
||||
);
|
||||
|
||||
return callback(credentials);
|
||||
};
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import RE2 from "re2";
|
||||
import { z } from "zod";
|
||||
|
||||
import { LdapPasswordRotationMethod } from "@app/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-types";
|
||||
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
|
||||
import {
|
||||
BaseCreateSecretRotationSchema,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas";
|
||||
import { PasswordRequirementsSchema } from "@app/ee/services/secret-rotation-v2/shared/general";
|
||||
import { SecretRotations } from "@app/lib/api-docs";
|
||||
import { DistinguishedNameRegex } from "@app/lib/regex";
|
||||
import { DistinguishedNameRegex, UserPrincipalNameRegex } from "@app/lib/regex";
|
||||
import { SecretNameSchema } from "@app/server/lib/schemas";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
|
||||
@@ -26,10 +26,16 @@ const LdapPasswordRotationParametersSchema = z.object({
|
||||
dn: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(new RE2(DistinguishedNameRegex), "Invalid DN format, ie; CN=user,OU=users,DC=example,DC=com")
|
||||
.min(1, "Distinguished Name (DN) Required")
|
||||
.min(1, "DN/UPN required")
|
||||
.refine((value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value), {
|
||||
message: "Invalid DN/UPN format"
|
||||
})
|
||||
.describe(SecretRotations.PARAMETERS.LDAP_PASSWORD.dn),
|
||||
passwordRequirements: PasswordRequirementsSchema.optional()
|
||||
passwordRequirements: PasswordRequirementsSchema.optional(),
|
||||
rotationMethod: z
|
||||
.nativeEnum(LdapPasswordRotationMethod)
|
||||
.optional()
|
||||
.describe(SecretRotations.PARAMETERS.LDAP_PASSWORD.rotationMethod)
|
||||
});
|
||||
|
||||
const LdapPasswordRotationSecretsMappingSchema = z.object({
|
||||
@@ -50,10 +56,28 @@ export const LdapPasswordRotationSchema = BaseSecretRotationSchema(SecretRotatio
|
||||
secretsMapping: LdapPasswordRotationSecretsMappingSchema
|
||||
});
|
||||
|
||||
export const CreateLdapPasswordRotationSchema = BaseCreateSecretRotationSchema(SecretRotation.LdapPassword).extend({
|
||||
parameters: LdapPasswordRotationParametersSchema,
|
||||
secretsMapping: LdapPasswordRotationSecretsMappingSchema
|
||||
});
|
||||
export const CreateLdapPasswordRotationSchema = BaseCreateSecretRotationSchema(SecretRotation.LdapPassword)
|
||||
.extend({
|
||||
parameters: LdapPasswordRotationParametersSchema,
|
||||
secretsMapping: LdapPasswordRotationSecretsMappingSchema,
|
||||
temporaryParameters: z
|
||||
.object({
|
||||
password: z.string().min(1, "Password required").describe(SecretRotations.PARAMETERS.LDAP_PASSWORD.password)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
if (
|
||||
val.parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal &&
|
||||
!val.temporaryParameters?.password
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Password required",
|
||||
path: ["temporaryParameters", "password"]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const UpdateLdapPasswordRotationSchema = BaseUpdateSecretRotationSchema(SecretRotation.LdapPassword).extend({
|
||||
parameters: LdapPasswordRotationParametersSchema.optional(),
|
||||
|
@@ -9,6 +9,11 @@ import {
|
||||
LdapPasswordRotationSchema
|
||||
} from "./ldap-password-rotation-schemas";
|
||||
|
||||
export enum LdapPasswordRotationMethod {
|
||||
ConnectionPrincipal = "connection-principal",
|
||||
TargetPrincipal = "target-principal"
|
||||
}
|
||||
|
||||
export type TLdapPasswordRotation = z.infer<typeof LdapPasswordRotationSchema>;
|
||||
|
||||
export type TLdapPasswordRotationInput = z.infer<typeof CreateLdapPasswordRotationSchema>;
|
||||
|
@@ -1,12 +1,13 @@
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
|
||||
import { AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./auth0-client-secret";
|
||||
import { AWS_IAM_USER_SECRET_ROTATION_LIST_OPTION } from "./aws-iam-user-secret";
|
||||
import { AZURE_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./azure-client-secret";
|
||||
import { LDAP_PASSWORD_ROTATION_LIST_OPTION } from "./ldap-password";
|
||||
import { LDAP_PASSWORD_ROTATION_LIST_OPTION, TLdapPasswordRotation } from "./ldap-password";
|
||||
import { MSSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mssql-credentials";
|
||||
import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials";
|
||||
import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums";
|
||||
@@ -15,7 +16,8 @@ import {
|
||||
TSecretRotationV2,
|
||||
TSecretRotationV2GeneratedCredentials,
|
||||
TSecretRotationV2ListItem,
|
||||
TSecretRotationV2Raw
|
||||
TSecretRotationV2Raw,
|
||||
TUpdateSecretRotationV2DTO
|
||||
} from "./secret-rotation-v2-types";
|
||||
|
||||
const SECRET_ROTATION_LIST_OPTIONS: Record<SecretRotation, TSecretRotationV2ListItem> = {
|
||||
@@ -228,3 +230,30 @@ export const parseRotationErrorMessage = (err: unknown): string => {
|
||||
? errorMessage
|
||||
: `${errorMessage.substring(0, MAX_MESSAGE_LENGTH - 3)}...`;
|
||||
};
|
||||
|
||||
function haveUnequalProperties<T>(obj1: T, obj2: T, properties: (keyof T)[]): boolean {
|
||||
return properties.some((prop) => obj1[prop] !== obj2[prop]);
|
||||
}
|
||||
|
||||
export const throwOnImmutableParameterUpdate = (
|
||||
updatePayload: TUpdateSecretRotationV2DTO,
|
||||
secretRotation: TSecretRotationV2Raw
|
||||
) => {
|
||||
if (!updatePayload.parameters) return;
|
||||
|
||||
switch (updatePayload.type) {
|
||||
case SecretRotation.LdapPassword:
|
||||
if (
|
||||
haveUnequalProperties(
|
||||
updatePayload.parameters as TLdapPasswordRotation["parameters"],
|
||||
secretRotation.parameters as TLdapPasswordRotation["parameters"],
|
||||
["rotationMethod", "dn"]
|
||||
)
|
||||
) {
|
||||
throw new BadRequestError({ message: "Cannot update rotation method or DN" });
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// do nothing
|
||||
}
|
||||
};
|
||||
|
@@ -25,7 +25,8 @@ import {
|
||||
getNextUtcRotationInterval,
|
||||
getSecretRotationRotateSecretJobOptions,
|
||||
listSecretRotationOptions,
|
||||
parseRotationErrorMessage
|
||||
parseRotationErrorMessage,
|
||||
throwOnImmutableParameterUpdate
|
||||
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-fns";
|
||||
import {
|
||||
SECRET_ROTATION_CONNECTION_MAP,
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
TSecretRotationV2,
|
||||
TSecretRotationV2GeneratedCredentials,
|
||||
TSecretRotationV2Raw,
|
||||
TSecretRotationV2TemporaryParameters,
|
||||
TSecretRotationV2WithConnection,
|
||||
TUpdateSecretRotationV2DTO
|
||||
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
|
||||
@@ -112,7 +114,8 @@ const MAX_GENERATED_CREDENTIALS_LENGTH = 2;
|
||||
|
||||
type TRotationFactoryImplementation = TRotationFactory<
|
||||
TSecretRotationV2WithConnection,
|
||||
TSecretRotationV2GeneratedCredentials
|
||||
TSecretRotationV2GeneratedCredentials,
|
||||
TSecretRotationV2TemporaryParameters
|
||||
>;
|
||||
const SECRET_ROTATION_FACTORY_MAP: Record<SecretRotation, TRotationFactoryImplementation> = {
|
||||
[SecretRotation.PostgresCredentials]: sqlCredentialsRotationFactory as TRotationFactoryImplementation,
|
||||
@@ -400,6 +403,7 @@ export const secretRotationV2ServiceFactory = ({
|
||||
environment,
|
||||
rotateAtUtc = { hours: 0, minutes: 0 },
|
||||
secretsMapping,
|
||||
temporaryParameters,
|
||||
...payload
|
||||
}: TCreateSecretRotationV2DTO,
|
||||
actor: OrgServiceActor
|
||||
@@ -546,7 +550,7 @@ export const secretRotationV2ServiceFactory = ({
|
||||
|
||||
return createdRotation;
|
||||
});
|
||||
});
|
||||
}, temporaryParameters);
|
||||
|
||||
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);
|
||||
await snapshotService.performSnapshot(folder.id);
|
||||
@@ -585,10 +589,7 @@ export const secretRotationV2ServiceFactory = ({
|
||||
}
|
||||
};
|
||||
|
||||
const updateSecretRotation = async (
|
||||
{ type, rotationId, ...payload }: TUpdateSecretRotationV2DTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const updateSecretRotation = async (dto: TUpdateSecretRotationV2DTO, actor: OrgServiceActor) => {
|
||||
const plan = await licenseService.getPlan(actor.orgId);
|
||||
|
||||
if (!plan.secretRotation)
|
||||
@@ -596,6 +597,8 @@ export const secretRotationV2ServiceFactory = ({
|
||||
message: "Failed to update secret rotation due to plan restriction. Upgrade plan to update secret rotations."
|
||||
});
|
||||
|
||||
const { type, rotationId, ...payload } = dto;
|
||||
|
||||
const secretRotation = await secretRotationV2DAL.findById(rotationId);
|
||||
|
||||
if (!secretRotation)
|
||||
@@ -603,6 +606,8 @@ export const secretRotationV2ServiceFactory = ({
|
||||
message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID ${rotationId}`
|
||||
});
|
||||
|
||||
throwOnImmutableParameterUpdate(dto, secretRotation);
|
||||
|
||||
const { folder, environment, projectId, folderId, connection } = secretRotation;
|
||||
const secretsMapping = secretRotation.secretsMapping as TSecretRotationV2["secretsMapping"];
|
||||
|
||||
@@ -877,6 +882,7 @@ export const secretRotationV2ServiceFactory = ({
|
||||
const inactiveIndex = (activeIndex + 1) % MAX_GENERATED_CREDENTIALS_LENGTH;
|
||||
|
||||
const inactiveCredentials = generatedCredentials[inactiveIndex];
|
||||
const activeCredentials = generatedCredentials[activeIndex];
|
||||
|
||||
const rotationFactory = SECRET_ROTATION_FACTORY_MAP[type as SecretRotation](
|
||||
{
|
||||
@@ -887,73 +893,77 @@ export const secretRotationV2ServiceFactory = ({
|
||||
kmsService
|
||||
);
|
||||
|
||||
const updatedRotation = await rotationFactory.rotateCredentials(inactiveCredentials, async (newCredentials) => {
|
||||
const updatedCredentials = [...generatedCredentials];
|
||||
updatedCredentials[inactiveIndex] = newCredentials;
|
||||
const updatedRotation = await rotationFactory.rotateCredentials(
|
||||
inactiveCredentials,
|
||||
async (newCredentials) => {
|
||||
const updatedCredentials = [...generatedCredentials];
|
||||
updatedCredentials[inactiveIndex] = newCredentials;
|
||||
|
||||
const encryptedUpdatedCredentials = await encryptSecretRotationCredentials({
|
||||
projectId,
|
||||
generatedCredentials: updatedCredentials as TSecretRotationV2GeneratedCredentials,
|
||||
kmsService
|
||||
});
|
||||
|
||||
return secretRotationV2DAL.transaction(async (tx) => {
|
||||
const secretsPayload = rotationFactory.getSecretsPayload(newCredentials);
|
||||
|
||||
const { encryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId
|
||||
const encryptedUpdatedCredentials = await encryptSecretRotationCredentials({
|
||||
projectId,
|
||||
generatedCredentials: updatedCredentials as TSecretRotationV2GeneratedCredentials,
|
||||
kmsService
|
||||
});
|
||||
|
||||
// update mapped secrets with new credential values
|
||||
await fnSecretBulkUpdate({
|
||||
folderId,
|
||||
orgId: connection.orgId,
|
||||
tx,
|
||||
inputSecrets: secretsPayload.map(({ key, value }) => ({
|
||||
filter: {
|
||||
key,
|
||||
folderId,
|
||||
type: SecretType.Shared
|
||||
},
|
||||
data: {
|
||||
encryptedValue: encryptor({
|
||||
plainText: Buffer.from(value)
|
||||
}).cipherTextBlob,
|
||||
references: []
|
||||
}
|
||||
})),
|
||||
secretDAL: secretV2BridgeDAL,
|
||||
secretVersionDAL: secretVersionV2BridgeDAL,
|
||||
secretVersionTagDAL: secretVersionTagV2BridgeDAL,
|
||||
secretTagDAL,
|
||||
resourceMetadataDAL
|
||||
});
|
||||
return secretRotationV2DAL.transaction(async (tx) => {
|
||||
const secretsPayload = rotationFactory.getSecretsPayload(newCredentials);
|
||||
|
||||
const currentTime = new Date();
|
||||
const { encryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
return secretRotationV2DAL.updateById(
|
||||
secretRotation.id,
|
||||
{
|
||||
encryptedGeneratedCredentials: encryptedUpdatedCredentials,
|
||||
activeIndex: inactiveIndex,
|
||||
isLastRotationManual: isManualRotation,
|
||||
lastRotatedAt: currentTime,
|
||||
lastRotationAttemptedAt: currentTime,
|
||||
nextRotationAt: calculateNextRotationAt({
|
||||
...(secretRotation as TSecretRotationV2),
|
||||
rotationStatus: SecretRotationStatus.Success,
|
||||
// update mapped secrets with new credential values
|
||||
await fnSecretBulkUpdate({
|
||||
folderId,
|
||||
orgId: connection.orgId,
|
||||
tx,
|
||||
inputSecrets: secretsPayload.map(({ key, value }) => ({
|
||||
filter: {
|
||||
key,
|
||||
folderId,
|
||||
type: SecretType.Shared
|
||||
},
|
||||
data: {
|
||||
encryptedValue: encryptor({
|
||||
plainText: Buffer.from(value)
|
||||
}).cipherTextBlob,
|
||||
references: []
|
||||
}
|
||||
})),
|
||||
secretDAL: secretV2BridgeDAL,
|
||||
secretVersionDAL: secretVersionV2BridgeDAL,
|
||||
secretVersionTagDAL: secretVersionTagV2BridgeDAL,
|
||||
secretTagDAL,
|
||||
resourceMetadataDAL
|
||||
});
|
||||
|
||||
const currentTime = new Date();
|
||||
|
||||
return secretRotationV2DAL.updateById(
|
||||
secretRotation.id,
|
||||
{
|
||||
encryptedGeneratedCredentials: encryptedUpdatedCredentials,
|
||||
activeIndex: inactiveIndex,
|
||||
isLastRotationManual: isManualRotation,
|
||||
lastRotatedAt: currentTime,
|
||||
isManualRotation
|
||||
}),
|
||||
rotationStatus: SecretRotationStatus.Success,
|
||||
lastRotationJobId: jobId,
|
||||
encryptedLastRotationMessage: null
|
||||
},
|
||||
tx
|
||||
);
|
||||
});
|
||||
});
|
||||
lastRotationAttemptedAt: currentTime,
|
||||
nextRotationAt: calculateNextRotationAt({
|
||||
...(secretRotation as TSecretRotationV2),
|
||||
rotationStatus: SecretRotationStatus.Success,
|
||||
lastRotatedAt: currentTime,
|
||||
isManualRotation
|
||||
}),
|
||||
rotationStatus: SecretRotationStatus.Success,
|
||||
lastRotationJobId: jobId,
|
||||
encryptedLastRotationMessage: null
|
||||
},
|
||||
tx
|
||||
);
|
||||
});
|
||||
},
|
||||
activeCredentials
|
||||
);
|
||||
|
||||
await auditLogService.createAuditLog({
|
||||
...(auditLogInfo ?? {
|
||||
|
@@ -87,6 +87,8 @@ export type TSecretRotationV2ListItem =
|
||||
| TLdapPasswordRotationListItem
|
||||
| TAwsIamUserSecretRotationListItem;
|
||||
|
||||
export type TSecretRotationV2TemporaryParameters = TLdapPasswordRotationInput["temporaryParameters"] | undefined;
|
||||
|
||||
export type TSecretRotationV2Raw = NonNullable<Awaited<ReturnType<TSecretRotationV2DALFactory["findById"]>>>;
|
||||
|
||||
export type TListSecretRotationsV2ByProjectId = {
|
||||
@@ -120,6 +122,7 @@ export type TCreateSecretRotationV2DTO = Pick<
|
||||
environment: string;
|
||||
isAutoRotationEnabled?: boolean;
|
||||
rotateAtUtc?: TRotateAtUtc;
|
||||
temporaryParameters?: TSecretRotationV2TemporaryParameters;
|
||||
};
|
||||
|
||||
export type TUpdateSecretRotationV2DTO = Partial<
|
||||
@@ -186,8 +189,12 @@ export type TSecretRotationSendNotificationJobPayload = {
|
||||
// transactional behavior. By passing in the rotation mutation, if this mutation fails we can roll back the
|
||||
// third party credential changes (when supported), preventing credentials getting out of sync
|
||||
|
||||
export type TRotationFactoryIssueCredentials<T extends TSecretRotationV2GeneratedCredentials> = (
|
||||
callback: (newCredentials: T[number]) => Promise<TSecretRotationV2Raw>
|
||||
export type TRotationFactoryIssueCredentials<
|
||||
T extends TSecretRotationV2GeneratedCredentials,
|
||||
P extends TSecretRotationV2TemporaryParameters = undefined
|
||||
> = (
|
||||
callback: (newCredentials: T[number]) => Promise<TSecretRotationV2Raw>,
|
||||
temporaryParameters?: P
|
||||
) => Promise<TSecretRotationV2Raw>;
|
||||
|
||||
export type TRotationFactoryRevokeCredentials<T extends TSecretRotationV2GeneratedCredentials> = (
|
||||
@@ -197,7 +204,8 @@ export type TRotationFactoryRevokeCredentials<T extends TSecretRotationV2Generat
|
||||
|
||||
export type TRotationFactoryRotateCredentials<T extends TSecretRotationV2GeneratedCredentials> = (
|
||||
credentialsToRevoke: T[number] | undefined,
|
||||
callback: (newCredentials: T[number]) => Promise<TSecretRotationV2Raw>
|
||||
callback: (newCredentials: T[number]) => Promise<TSecretRotationV2Raw>,
|
||||
activeCredentials: T[number]
|
||||
) => Promise<TSecretRotationV2Raw>;
|
||||
|
||||
export type TRotationFactoryGetSecretsPayload<T extends TSecretRotationV2GeneratedCredentials> = (
|
||||
@@ -206,13 +214,14 @@ export type TRotationFactoryGetSecretsPayload<T extends TSecretRotationV2Generat
|
||||
|
||||
export type TRotationFactory<
|
||||
T extends TSecretRotationV2WithConnection,
|
||||
C extends TSecretRotationV2GeneratedCredentials
|
||||
C extends TSecretRotationV2GeneratedCredentials,
|
||||
P extends TSecretRotationV2TemporaryParameters = undefined
|
||||
> = (
|
||||
secretRotation: T,
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update" | "updateById">,
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
|
||||
) => {
|
||||
issueCredentials: TRotationFactoryIssueCredentials<C>;
|
||||
issueCredentials: TRotationFactoryIssueCredentials<C, P>;
|
||||
revokeCredentials: TRotationFactoryRevokeCredentials<C>;
|
||||
rotateCredentials: TRotationFactoryRotateCredentials<C>;
|
||||
getSecretsPayload: TRotationFactoryGetSecretsPayload<C>;
|
||||
|
@@ -2063,7 +2063,7 @@ export const AppConnections = {
|
||||
LDAP: {
|
||||
provider: "The type of LDAP provider. Determines provider-specific behaviors.",
|
||||
url: "The LDAP/LDAPS URL to connect to (e.g., 'ldap://domain-or-ip:389' or 'ldaps://domain-or-ip:636').",
|
||||
dn: "The Distinguished Name (DN) of the principal to bind with (e.g., 'CN=John,CN=Users,DC=example,DC=com').",
|
||||
dn: "The Distinguished Name (DN) or User Principal Name (UPN) of the principal to bind with (e.g., 'CN=John,CN=Users,DC=example,DC=com').",
|
||||
password: "The password to bind with for authentication.",
|
||||
sslRejectUnauthorized:
|
||||
"Whether or not to reject unauthorized SSL certificates (true/false) when using ldaps://. Set to false only in test environments.",
|
||||
@@ -2308,7 +2308,10 @@ export const SecretRotations = {
|
||||
clientId: "The client ID of the Azure Application to rotate the client secret for."
|
||||
},
|
||||
LDAP_PASSWORD: {
|
||||
dn: "The Distinguished Name (DN) of the principal to rotate the password for."
|
||||
dn: "The Distinguished Name (DN) or User Principal Name (UPN) of the principal to rotate the password for.",
|
||||
rotationMethod:
|
||||
'Whether the rotation should be performed by the LDAP "connection-principal" or the "target-principal" (defaults to \'connection-principal\').',
|
||||
password: 'The password of the provided principal if "parameters.rotationMethod" is set to "target-principal".'
|
||||
},
|
||||
GENERAL: {
|
||||
PASSWORD_REQUIREMENTS: {
|
||||
@@ -2342,7 +2345,7 @@ export const SecretRotations = {
|
||||
clientSecret: "The name of the secret that the rotated client secret will be mapped to."
|
||||
},
|
||||
LDAP_PASSWORD: {
|
||||
dn: "The name of the secret that the Distinguished Name (DN) of the principal will be mapped to.",
|
||||
dn: "The name of the secret that the Distinguished Name (DN) or User Principal Name (UPN) of the principal will be mapped to.",
|
||||
password: "The name of the secret that the rotated password will be mapped to."
|
||||
},
|
||||
AWS_IAM_USER_SECRET: {
|
||||
|
@@ -1,6 +1,8 @@
|
||||
import { Knex } from "knex";
|
||||
import { Compare, Filter, parse } from "scim2-parse-filter";
|
||||
|
||||
import { TableName } from "@app/db/schemas";
|
||||
|
||||
const appendParentToGroupingOperator = (parentPath: string, filter: Filter) => {
|
||||
if (filter.op !== "[]" && filter.op !== "and" && filter.op !== "or" && filter.op !== "not") {
|
||||
return { ...filter, attrPath: `${parentPath}.${(filter as Compare).attrPath}` };
|
||||
@@ -27,8 +29,12 @@ const processDynamicQuery = (
|
||||
const { scimFilterAst, query } = stack.pop()!;
|
||||
switch (scimFilterAst.op) {
|
||||
case "eq": {
|
||||
let sanitizedValue = scimFilterAst.compValue;
|
||||
const attrPath = getAttributeField(scimFilterAst.attrPath);
|
||||
if (attrPath) void query.where(attrPath, scimFilterAst.compValue);
|
||||
if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") {
|
||||
sanitizedValue = sanitizedValue.toLowerCase();
|
||||
}
|
||||
if (attrPath) void query.where(attrPath, sanitizedValue);
|
||||
break;
|
||||
}
|
||||
case "pr": {
|
||||
@@ -62,18 +68,30 @@ const processDynamicQuery = (
|
||||
break;
|
||||
}
|
||||
case "ew": {
|
||||
let sanitizedValue = scimFilterAst.compValue;
|
||||
const attrPath = getAttributeField(scimFilterAst.attrPath);
|
||||
if (attrPath) void query.whereILike(attrPath, `%${scimFilterAst.compValue}`);
|
||||
if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") {
|
||||
sanitizedValue = sanitizedValue.toLowerCase();
|
||||
}
|
||||
if (attrPath) void query.whereILike(attrPath, `%${sanitizedValue}`);
|
||||
break;
|
||||
}
|
||||
case "co": {
|
||||
let sanitizedValue = scimFilterAst.compValue;
|
||||
const attrPath = getAttributeField(scimFilterAst.attrPath);
|
||||
if (attrPath) void query.whereILike(attrPath, `%${scimFilterAst.compValue}%`);
|
||||
if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") {
|
||||
sanitizedValue = sanitizedValue.toLowerCase();
|
||||
}
|
||||
if (attrPath) void query.whereILike(attrPath, `%${sanitizedValue}%`);
|
||||
break;
|
||||
}
|
||||
case "ne": {
|
||||
let sanitizedValue = scimFilterAst.compValue;
|
||||
const attrPath = getAttributeField(scimFilterAst.attrPath);
|
||||
if (attrPath) void query.whereNot(attrPath, "=", scimFilterAst.compValue);
|
||||
if (attrPath === `${TableName.Users}.email` && typeof sanitizedValue === "string") {
|
||||
sanitizedValue = sanitizedValue.toLowerCase();
|
||||
}
|
||||
if (attrPath) void query.whereNot(attrPath, "=", sanitizedValue);
|
||||
break;
|
||||
}
|
||||
case "and": {
|
||||
|
@@ -1,3 +1,11 @@
|
||||
import RE2 from "re2";
|
||||
|
||||
export const DistinguishedNameRegex =
|
||||
// DN format, ie; CN=user,OU=users,DC=example,DC=com
|
||||
/^(?:(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*)(?:,(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*))*)?$/;
|
||||
new RE2(
|
||||
/^(?:(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*)(?:,(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*))*)?$/
|
||||
);
|
||||
|
||||
export const UserPrincipalNameRegex = new RE2(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}$/);
|
||||
|
||||
export const LdapUrlRegex = new RE2(/^ldaps?:\/\//);
|
||||
|
@@ -9,7 +9,7 @@ interface SlugSchemaInputs {
|
||||
field?: string;
|
||||
}
|
||||
|
||||
export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInputs = {}) => {
|
||||
export const slugSchema = ({ min = 1, max = 64, field = "Slug" }: SlugSchemaInputs = {}) => {
|
||||
return z
|
||||
.string()
|
||||
.trim()
|
||||
|
@@ -625,7 +625,6 @@ export const registerRoutes = async (
|
||||
|
||||
const userService = userServiceFactory({
|
||||
userDAL,
|
||||
userAliasDAL,
|
||||
orgMembershipDAL,
|
||||
tokenService,
|
||||
permissionService,
|
||||
|
@@ -16,7 +16,12 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => {
|
||||
method: "POST",
|
||||
schema: {
|
||||
body: z.object({
|
||||
inviteeEmails: z.array(z.string().trim().email()),
|
||||
inviteeEmails: z
|
||||
.string()
|
||||
.trim()
|
||||
.email()
|
||||
.array()
|
||||
.refine((val) => val.every((el) => el === el.toLowerCase()), "Email must be lowercase"),
|
||||
organizationId: z.string().trim(),
|
||||
projects: z
|
||||
.object({
|
||||
@@ -115,7 +120,11 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
email: z.string().trim().email(),
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.email()
|
||||
.refine((val) => val === val.toLowerCase(), "Email must be lowercase"),
|
||||
organizationId: z.string().trim(),
|
||||
code: z.string().trim()
|
||||
}),
|
||||
|
@@ -46,6 +46,54 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/duplicate-accounts",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
response: {
|
||||
200: z.object({
|
||||
users: UsersSchema.extend({
|
||||
isMyAccount: z.boolean(),
|
||||
organizations: z.object({ name: z.string(), slug: z.string() }).array()
|
||||
}).array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }),
|
||||
handler: async (req) => {
|
||||
if (req.auth.authMode === AuthMode.JWT && req.auth.user.email) {
|
||||
const users = await server.services.user.getAllMyAccounts(req.auth.user.email, req.permission.id);
|
||||
return { users };
|
||||
}
|
||||
return { users: [] };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/remove-duplicate-accounts",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
response: {
|
||||
200: z.object({
|
||||
message: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }),
|
||||
handler: async (req) => {
|
||||
if (req.auth.authMode === AuthMode.JWT && req.auth.user.email) {
|
||||
await server.services.user.removeMyDuplicateAccounts(req.auth.user.email, req.permission.id);
|
||||
}
|
||||
return { message: "Removed all duplicate accounts" };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/private-key",
|
||||
|
@@ -27,8 +27,19 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
|
||||
projectId: z.string().describe(PROJECT_USERS.INVITE_MEMBER.projectId)
|
||||
}),
|
||||
body: z.object({
|
||||
emails: z.string().email().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.emails),
|
||||
usernames: z.string().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.usernames),
|
||||
emails: z
|
||||
.string()
|
||||
.email()
|
||||
.array()
|
||||
.default([])
|
||||
.describe(PROJECT_USERS.INVITE_MEMBER.emails)
|
||||
.refine((val) => val.every((el) => el === el.toLowerCase()), "Email must be lowercase"),
|
||||
usernames: z
|
||||
.string()
|
||||
.array()
|
||||
.default([])
|
||||
.describe(PROJECT_USERS.INVITE_MEMBER.usernames)
|
||||
.refine((val) => val.every((el) => el === el.toLowerCase()), "Username must be lowercase"),
|
||||
roleSlugs: z.string().array().min(1).optional().describe(PROJECT_USERS.INVITE_MEMBER.roleSlugs)
|
||||
}),
|
||||
response: {
|
||||
@@ -92,8 +103,19 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
|
||||
projectId: z.string().describe(PROJECT_USERS.REMOVE_MEMBER.projectId)
|
||||
}),
|
||||
body: z.object({
|
||||
emails: z.string().email().array().default([]).describe(PROJECT_USERS.REMOVE_MEMBER.emails),
|
||||
usernames: z.string().array().default([]).describe(PROJECT_USERS.REMOVE_MEMBER.usernames)
|
||||
emails: z
|
||||
.string()
|
||||
.email()
|
||||
.array()
|
||||
.default([])
|
||||
.describe(PROJECT_USERS.REMOVE_MEMBER.emails)
|
||||
.refine((val) => val.every((el) => el === el.toLowerCase()), "Email must be lowercase"),
|
||||
usernames: z
|
||||
.string()
|
||||
.array()
|
||||
.default([])
|
||||
.describe(PROJECT_USERS.REMOVE_MEMBER.usernames)
|
||||
.refine((val) => val.every((el) => el === el.toLowerCase()), "Username must be lowercase")
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
|
@@ -1,8 +1,7 @@
|
||||
import RE2 from "re2";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AppConnections } from "@app/lib/api-docs";
|
||||
import { DistinguishedNameRegex } from "@app/lib/regex";
|
||||
import { DistinguishedNameRegex, LdapUrlRegex, UserPrincipalNameRegex } from "@app/lib/regex";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import {
|
||||
BaseAppConnectionSchema,
|
||||
@@ -14,17 +13,14 @@ import { LdapConnectionMethod, LdapProvider } from "./ldap-connection-enums";
|
||||
|
||||
export const LdapConnectionSimpleBindCredentialsSchema = z.object({
|
||||
provider: z.nativeEnum(LdapProvider).describe(AppConnections.CREDENTIALS.LDAP.provider),
|
||||
url: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "URL required")
|
||||
.regex(new RE2(/^ldaps?:\/\//))
|
||||
.describe(AppConnections.CREDENTIALS.LDAP.url),
|
||||
url: z.string().trim().min(1, "URL required").regex(LdapUrlRegex).describe(AppConnections.CREDENTIALS.LDAP.url),
|
||||
dn: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(new RE2(DistinguishedNameRegex), "Invalid DN format, ie; CN=user,OU=users,DC=example,DC=com")
|
||||
.min(1, "Distinguished Name (DN) required")
|
||||
.min(1, "DN/UPN required")
|
||||
.refine((value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value), {
|
||||
message: "Invalid DN/UPN format"
|
||||
})
|
||||
.describe(AppConnections.CREDENTIALS.LDAP.dn),
|
||||
password: z.string().trim().min(1, "Password required").describe(AppConnections.CREDENTIALS.LDAP.password),
|
||||
sslRejectUnauthorized: z.boolean().optional().describe(AppConnections.CREDENTIALS.LDAP.sslRejectUnauthorized),
|
||||
|
@@ -199,9 +199,12 @@ export const authLoginServiceFactory = ({
|
||||
providerAuthToken,
|
||||
clientPublicKey
|
||||
}: TLoginGenServerPublicKeyDTO) => {
|
||||
const userEnc = await userDAL.findUserEncKeyByUsername({
|
||||
// akhilmhdh: case sensitive email resolution
|
||||
const usersByUsername = await userDAL.findUserEncKeyByUsername({
|
||||
username: email
|
||||
});
|
||||
const userEnc =
|
||||
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
|
||||
|
||||
const serverCfg = await getServerCfg();
|
||||
|
||||
@@ -250,9 +253,12 @@ export const authLoginServiceFactory = ({
|
||||
}: TLoginClientProofDTO) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
const userEnc = await userDAL.findUserEncKeyByUsername({
|
||||
// akhilmhdh: case sensitive email resolution
|
||||
const usersByUsername = await userDAL.findUserEncKeyByUsername({
|
||||
username: email
|
||||
});
|
||||
const userEnc =
|
||||
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
|
||||
if (!userEnc) throw new Error("Failed to find user");
|
||||
const user = await userDAL.findById(userEnc.userId);
|
||||
const cfg = getConfig();
|
||||
@@ -649,10 +655,12 @@ export const authLoginServiceFactory = ({
|
||||
* OAuth2 login for google,github, and other oauth2 provider
|
||||
* */
|
||||
const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => {
|
||||
let user = await userDAL.findUserByUsername(email);
|
||||
// akhilmhdh: case sensitive email resolution
|
||||
const usersByUsername = await userDAL.findUserByUsername(email);
|
||||
let user = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
|
||||
const serverCfg = await getServerCfg();
|
||||
|
||||
if (serverCfg.enabledLoginMethods) {
|
||||
if (serverCfg.enabledLoginMethods && user) {
|
||||
switch (authMethod) {
|
||||
case AuthMethod.GITHUB: {
|
||||
if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITHUB)) {
|
||||
@@ -715,8 +723,8 @@ export const authLoginServiceFactory = ({
|
||||
}
|
||||
|
||||
user = await userDAL.create({
|
||||
username: email,
|
||||
email,
|
||||
username: email.trim().toLowerCase(),
|
||||
email: email.trim().toLowerCase(),
|
||||
isEmailVerified: true,
|
||||
firstName,
|
||||
lastName,
|
||||
@@ -814,11 +822,14 @@ export const authLoginServiceFactory = ({
|
||||
? decodedProviderToken.orgId
|
||||
: undefined;
|
||||
|
||||
const userEnc = await userDAL.findUserEncKeyByUsername({
|
||||
// akhilmhdh: case sensitive email resolution
|
||||
const usersByUsername = await userDAL.findUserEncKeyByUsername({
|
||||
username: email
|
||||
});
|
||||
if (!userEnc) throw new BadRequestError({ message: "Invalid token" });
|
||||
if (!userEnc.serverEncryptedPrivateKey)
|
||||
const userEnc =
|
||||
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
|
||||
|
||||
if (!userEnc?.serverEncryptedPrivateKey)
|
||||
throw new BadRequestError({ message: "Key handoff incomplete. Please try logging in again." });
|
||||
|
||||
const token = await generateUserTokens({
|
||||
|
@@ -121,7 +121,10 @@ export const authPaswordServiceFactory = ({
|
||||
*/
|
||||
const sendPasswordResetEmail = async (email: string) => {
|
||||
const sendEmail = async () => {
|
||||
const user = await userDAL.findUserByUsername(email);
|
||||
const users = await userDAL.findUserByUsername(email);
|
||||
// akhilmhdh: case sensitive email resolution
|
||||
const user = users?.length > 1 ? users.find((el) => el.username === email) : users?.[0];
|
||||
if (!user) throw new BadRequestError({ message: "Failed to find user data" });
|
||||
|
||||
if (user && user.isAccepted) {
|
||||
const cfg = getConfig();
|
||||
@@ -152,7 +155,10 @@ export const authPaswordServiceFactory = ({
|
||||
* */
|
||||
const verifyPasswordResetEmail = async (email: string, code: string) => {
|
||||
const cfg = getConfig();
|
||||
const user = await userDAL.findUserByUsername(email);
|
||||
const users = await userDAL.findUserByUsername(email);
|
||||
// akhilmhdh: case sensitive email resolution
|
||||
const user = users?.length > 1 ? users.find((el) => el.username === email) : users?.[0];
|
||||
if (!user) throw new BadRequestError({ message: "Failed to find user data" });
|
||||
|
||||
const userEnc = await userDAL.findUserEncKeyByUserId(user.id);
|
||||
|
||||
|
@@ -73,18 +73,27 @@ export const authSignupServiceFactory = ({
|
||||
}: TAuthSignupDep) => {
|
||||
// first step of signup. create user and send email
|
||||
const beginEmailSignupProcess = async (email: string) => {
|
||||
const isEmailInvalid = await isDisposableEmail(email);
|
||||
const sanitizedEmail = email.trim().toLowerCase();
|
||||
const isEmailInvalid = await isDisposableEmail(sanitizedEmail);
|
||||
if (isEmailInvalid) {
|
||||
throw new Error("Provided a disposable email");
|
||||
}
|
||||
|
||||
let user = await userDAL.findUserByUsername(email);
|
||||
// akhilmhdh: case sensitive email resolution
|
||||
const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail);
|
||||
let user =
|
||||
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0];
|
||||
if (user && user.isAccepted) {
|
||||
// TODO(akhilmhdh-pg): copy as old one. this needs to be changed due to security issues
|
||||
throw new Error("Failed to send verification code for complete account");
|
||||
throw new BadRequestError({ message: "Failed to send verification code for complete account" });
|
||||
}
|
||||
if (!user) {
|
||||
user = await userDAL.create({ authMethods: [AuthMethod.EMAIL], username: email, email, isGhost: false });
|
||||
user = await userDAL.create({
|
||||
authMethods: [AuthMethod.EMAIL],
|
||||
username: sanitizedEmail,
|
||||
email: sanitizedEmail,
|
||||
isGhost: false
|
||||
});
|
||||
}
|
||||
if (!user) throw new Error("Failed to create user");
|
||||
|
||||
@@ -96,7 +105,7 @@ export const authSignupServiceFactory = ({
|
||||
await smtpService.sendMail({
|
||||
template: SmtpTemplates.SignupEmailVerification,
|
||||
subjectLine: "Infisical confirmation code",
|
||||
recipients: [user.email as string],
|
||||
recipients: [sanitizedEmail],
|
||||
substitutions: {
|
||||
code: token
|
||||
}
|
||||
@@ -104,11 +113,15 @@ export const authSignupServiceFactory = ({
|
||||
};
|
||||
|
||||
const verifyEmailSignup = async (email: string, code: string) => {
|
||||
const user = await userDAL.findUserByUsername(email);
|
||||
const sanitizedEmail = email.trim().toLowerCase();
|
||||
const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail);
|
||||
const user =
|
||||
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0];
|
||||
if (!user || (user && user.isAccepted)) {
|
||||
// TODO(akhilmhdh): copy as old one. this needs to be changed due to security issues
|
||||
throw new Error("Failed to send verification code for complete account");
|
||||
}
|
||||
|
||||
const appCfg = getConfig();
|
||||
await tokenService.validateTokenForUser({
|
||||
type: TokenType.TOKEN_EMAIL_CONFIRMATION,
|
||||
@@ -153,12 +166,15 @@ export const authSignupServiceFactory = ({
|
||||
authorization,
|
||||
useDefaultOrg
|
||||
}: TCompleteAccountSignupDTO) => {
|
||||
const sanitizedEmail = email.trim().toLowerCase();
|
||||
const appCfg = getConfig();
|
||||
const serverCfg = await getServerCfg();
|
||||
|
||||
const user = await userDAL.findOne({ username: email });
|
||||
const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail);
|
||||
const user =
|
||||
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0];
|
||||
if (!user || (user && user.isAccepted)) {
|
||||
throw new Error("Failed to complete account for complete user");
|
||||
throw new BadRequestError({ message: "Failed to complete account for complete user" });
|
||||
}
|
||||
|
||||
let organizationId: string | null = null;
|
||||
@@ -315,7 +331,7 @@ export const authSignupServiceFactory = ({
|
||||
}
|
||||
|
||||
const updatedMembersips = await orgDAL.updateMembership(
|
||||
{ inviteEmail: email, status: OrgMembershipStatus.Invited },
|
||||
{ inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited },
|
||||
{ userId: user.id, status: OrgMembershipStatus.Accepted }
|
||||
);
|
||||
const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))];
|
||||
@@ -382,9 +398,9 @@ export const authSignupServiceFactory = ({
|
||||
* User signup flow when they are invited to join the org
|
||||
* */
|
||||
const completeAccountInvite = async ({
|
||||
email,
|
||||
ip,
|
||||
salt,
|
||||
email,
|
||||
password,
|
||||
verifier,
|
||||
firstName,
|
||||
@@ -399,7 +415,10 @@ export const authSignupServiceFactory = ({
|
||||
encryptedPrivateKeyTag,
|
||||
authorization
|
||||
}: TCompleteAccountInviteDTO) => {
|
||||
const user = await userDAL.findUserByUsername(email);
|
||||
const sanitizedEmail = email.trim().toLowerCase();
|
||||
const usersByUsername = await userDAL.findUserByUsername(sanitizedEmail);
|
||||
const user =
|
||||
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === sanitizedEmail) : usersByUsername?.[0];
|
||||
if (!user || (user && user.isAccepted)) {
|
||||
throw new Error("Failed to complete account for complete user");
|
||||
}
|
||||
@@ -407,7 +426,7 @@ export const authSignupServiceFactory = ({
|
||||
validateSignUpAuthorization(authorization, user.id);
|
||||
|
||||
const [orgMembership] = await orgDAL.findMembership({
|
||||
inviteEmail: email,
|
||||
inviteEmail: sanitizedEmail,
|
||||
status: OrgMembershipStatus.Invited
|
||||
});
|
||||
if (!orgMembership)
|
||||
@@ -454,7 +473,7 @@ export const authSignupServiceFactory = ({
|
||||
const serverGeneratedPrivateKey = await getUserPrivateKey(serverGeneratedPassword, {
|
||||
...systemGeneratedUserEncryptionKey
|
||||
});
|
||||
const encKeys = await generateUserSrpKeys(email, password, {
|
||||
const encKeys = await generateUserSrpKeys(sanitizedEmail, password, {
|
||||
publicKey: systemGeneratedUserEncryptionKey.publicKey,
|
||||
privateKey: serverGeneratedPrivateKey
|
||||
});
|
||||
@@ -505,7 +524,7 @@ export const authSignupServiceFactory = ({
|
||||
}
|
||||
|
||||
const updatedMembersips = await orgDAL.updateMembership(
|
||||
{ inviteEmail: email, status: OrgMembershipStatus.Invited },
|
||||
{ inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited },
|
||||
{ userId: us.id, status: OrgMembershipStatus.Accepted },
|
||||
tx
|
||||
);
|
||||
|
@@ -8,6 +8,7 @@ import {
|
||||
ProjectPermissionCertificateActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/ee/services/permission/project-permission";
|
||||
import { NotFoundError } from "@app/lib/errors";
|
||||
import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal";
|
||||
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
|
||||
import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal";
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
TGetCertPrivateKeyDTO,
|
||||
TRevokeCertDTO
|
||||
} from "./certificate-types";
|
||||
import { NotFoundError } from "@app/lib/errors";
|
||||
|
||||
type TCertificateServiceFactoryDep = {
|
||||
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "deleteById" | "update" | "find">;
|
||||
|
@@ -206,7 +206,7 @@ export const orgDALFactory = (db: TDbClient) => {
|
||||
.where(`${TableName.OrgMembership}.orgId`, orgId)
|
||||
.count("*")
|
||||
.join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
|
||||
.where({ isGhost: false })
|
||||
.where({ isGhost: false, [`${TableName.OrgMembership}.isActive` as "isActive"]: true })
|
||||
.first();
|
||||
|
||||
return parseInt((count as unknown as CountResult).count || "0", 10);
|
||||
|
@@ -827,7 +827,11 @@ export const orgServiceFactory = ({
|
||||
const users: Pick<TUsers, "id" | "firstName" | "lastName" | "email" | "username">[] = [];
|
||||
|
||||
for await (const inviteeEmail of inviteeEmails) {
|
||||
let inviteeUser = await userDAL.findUserByUsername(inviteeEmail, tx);
|
||||
const usersByUsername = await userDAL.findUserByUsername(inviteeEmail, tx);
|
||||
let inviteeUser =
|
||||
usersByUsername?.length > 1
|
||||
? usersByUsername.find((el) => el.username === inviteeEmail)
|
||||
: usersByUsername?.[0];
|
||||
|
||||
// if the user doesn't exist we create the user with the email
|
||||
if (!inviteeUser) {
|
||||
@@ -1239,10 +1243,13 @@ export const orgServiceFactory = ({
|
||||
* magic link and issue a temporary signup token for user to complete setting up their account
|
||||
*/
|
||||
const verifyUserToOrg = async ({ orgId, email, code }: TVerifyUserToOrgDTO) => {
|
||||
const user = await userDAL.findUserByUsername(email);
|
||||
const usersByUsername = await userDAL.findUserByUsername(email);
|
||||
const user =
|
||||
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
|
||||
if (!user) {
|
||||
throw new NotFoundError({ message: "User not found" });
|
||||
}
|
||||
|
||||
const [orgMembership] = await orgDAL.findMembership({
|
||||
[`${TableName.OrgMembership}.userId` as "userId"]: user.id,
|
||||
status: OrgMembershipStatus.Invited,
|
||||
|
@@ -257,8 +257,8 @@ export const superAdminServiceFactory = ({
|
||||
const adminSignUp = async ({
|
||||
lastName,
|
||||
firstName,
|
||||
salt,
|
||||
email,
|
||||
salt,
|
||||
password,
|
||||
verifier,
|
||||
publicKey,
|
||||
@@ -272,7 +272,8 @@ export const superAdminServiceFactory = ({
|
||||
userAgent
|
||||
}: TAdminSignUpDTO) => {
|
||||
const appCfg = getConfig();
|
||||
const existingUser = await userDAL.findOne({ email });
|
||||
const sanitizedEmail = email.trim().toLowerCase();
|
||||
const existingUser = await userDAL.findOne({ username: sanitizedEmail });
|
||||
if (existingUser) throw new BadRequestError({ name: "Admin sign up", message: "User already exists" });
|
||||
|
||||
const privateKey = await getUserPrivateKey(password, {
|
||||
@@ -292,8 +293,8 @@ export const superAdminServiceFactory = ({
|
||||
{
|
||||
firstName,
|
||||
lastName,
|
||||
username: email,
|
||||
email,
|
||||
username: sanitizedEmail,
|
||||
email: sanitizedEmail,
|
||||
superAdmin: true,
|
||||
isGhost: false,
|
||||
isAccepted: true,
|
||||
@@ -348,12 +349,13 @@ export const superAdminServiceFactory = ({
|
||||
|
||||
const bootstrapInstance = async ({ email, password, organizationName }: TAdminBootstrapInstanceDTO) => {
|
||||
const appCfg = getConfig();
|
||||
const sanitizedEmail = email.trim().toLowerCase();
|
||||
const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID);
|
||||
if (serverCfg?.initialized) {
|
||||
throw new BadRequestError({ message: "Instance has already been set up" });
|
||||
}
|
||||
|
||||
const existingUser = await userDAL.findOne({ email });
|
||||
const existingUser = await userDAL.findOne({ email: sanitizedEmail });
|
||||
if (existingUser) throw new BadRequestError({ name: "Instance initialization", message: "User already exists" });
|
||||
|
||||
const userInfo = await userDAL.transaction(async (tx) => {
|
||||
@@ -361,8 +363,8 @@ export const superAdminServiceFactory = ({
|
||||
{
|
||||
firstName: "Admin",
|
||||
lastName: "User",
|
||||
username: email,
|
||||
email,
|
||||
username: sanitizedEmail,
|
||||
email: sanitizedEmail,
|
||||
superAdmin: true,
|
||||
isGhost: false,
|
||||
isAccepted: true,
|
||||
@@ -372,7 +374,7 @@ export const superAdminServiceFactory = ({
|
||||
tx
|
||||
);
|
||||
const { tag, encoding, ciphertext, iv } = infisicalSymmetricEncypt(password);
|
||||
const encKeys = await generateUserSrpKeys(email, password);
|
||||
const encKeys = await generateUserSrpKeys(sanitizedEmail, password);
|
||||
|
||||
const userEnc = await userDAL.createUserEncryption(
|
||||
{
|
||||
|
@@ -8,16 +8,18 @@ import {
|
||||
TUserEncryptionKeys,
|
||||
TUserEncryptionKeysInsert,
|
||||
TUserEncryptionKeysUpdate,
|
||||
TUsers
|
||||
TUsers,
|
||||
UsersSchema
|
||||
} from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
|
||||
|
||||
export type TUserDALFactory = ReturnType<typeof userDALFactory>;
|
||||
|
||||
export const userDALFactory = (db: TDbClient) => {
|
||||
const userOrm = ormify(db, TableName.Users);
|
||||
const findUserByUsername = async (username: string, tx?: Knex) => userOrm.findOne({ username }, tx);
|
||||
const findUserByUsername = async (username: string, tx?: Knex) =>
|
||||
(tx || db)(TableName.Users).whereRaw('lower("username") = :username', { username: username.toLowerCase() });
|
||||
|
||||
const getUsersByFilter = async ({
|
||||
limit,
|
||||
@@ -41,7 +43,7 @@ export const userDALFactory = (db: TDbClient) => {
|
||||
.whereILike("email", `%${searchTerm}%`)
|
||||
.orWhereILike("firstName", `%${searchTerm}%`)
|
||||
.orWhereILike("lastName", `%${searchTerm}%`)
|
||||
.orWhereLike("username", `%${searchTerm}%`);
|
||||
.orWhereRaw('lower("username") like ?', `%${searchTerm}%`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,12 +67,11 @@ export const userDALFactory = (db: TDbClient) => {
|
||||
try {
|
||||
return await db
|
||||
.replicaNode()(TableName.Users)
|
||||
.whereRaw('lower("username") = :username', { username: username.toLowerCase() })
|
||||
.where({
|
||||
username,
|
||||
isGhost: false
|
||||
})
|
||||
.join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`)
|
||||
.first();
|
||||
.join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find user enc by email" });
|
||||
}
|
||||
@@ -168,6 +169,38 @@ export const userDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
const findAllMyAccounts = async (email: string) => {
|
||||
try {
|
||||
const doc = await db(TableName.Users)
|
||||
.where({ email })
|
||||
.leftJoin(TableName.OrgMembership, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
|
||||
.leftJoin(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`)
|
||||
.select(selectAllTableCols(TableName.Users))
|
||||
.select(
|
||||
db.ref("name").withSchema(TableName.Organization).as("orgName"),
|
||||
db.ref("slug").withSchema(TableName.Organization).as("orgSlug")
|
||||
);
|
||||
const formattedDoc = sqlNestRelationships({
|
||||
data: doc,
|
||||
key: "id",
|
||||
parentMapper: (el) => UsersSchema.parse(el),
|
||||
childrenMapper: [
|
||||
{
|
||||
key: "orgSlug",
|
||||
label: "organizations" as const,
|
||||
mapper: ({ orgSlug, orgName }) => ({
|
||||
slug: orgSlug,
|
||||
name: orgName
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
return formattedDoc;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Upsert user enc key" });
|
||||
}
|
||||
};
|
||||
|
||||
// USER ACTION FUNCTIONS
|
||||
// ---------------------
|
||||
const findOneUserAction = (filter: TUserActionsUpdate, tx?: Knex) => {
|
||||
@@ -200,6 +233,7 @@ export const userDALFactory = (db: TDbClient) => {
|
||||
createUserEncryption,
|
||||
findOneUserAction,
|
||||
createUserAction,
|
||||
getUsersByFilter
|
||||
getUsersByFilter,
|
||||
findAllMyAccounts
|
||||
};
|
||||
};
|
||||
|
@@ -9,7 +9,6 @@ import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-se
|
||||
import { TokenType } from "@app/services/auth-token/auth-token-types";
|
||||
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
|
||||
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
||||
import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
|
||||
|
||||
import { AuthMethod } from "../auth/auth-type";
|
||||
import { TGroupProjectDALFactory } from "../group-project/group-project-dal";
|
||||
@@ -21,7 +20,7 @@ type TUserServiceFactoryDep = {
|
||||
userDAL: Pick<
|
||||
TUserDALFactory,
|
||||
| "find"
|
||||
| "findOne"
|
||||
| "findUserByUsername"
|
||||
| "findById"
|
||||
| "transaction"
|
||||
| "updateById"
|
||||
@@ -31,8 +30,8 @@ type TUserServiceFactoryDep = {
|
||||
| "createUserAction"
|
||||
| "findUserEncKeyByUserId"
|
||||
| "delete"
|
||||
| "findAllMyAccounts"
|
||||
>;
|
||||
userAliasDAL: Pick<TUserAliasDALFactory, "find" | "insertMany">;
|
||||
groupProjectDAL: Pick<TGroupProjectDALFactory, "findByUserId">;
|
||||
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find" | "insertMany" | "findOne" | "updateById">;
|
||||
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser">;
|
||||
@@ -45,7 +44,6 @@ export type TUserServiceFactory = ReturnType<typeof userServiceFactory>;
|
||||
|
||||
export const userServiceFactory = ({
|
||||
userDAL,
|
||||
userAliasDAL,
|
||||
orgMembershipDAL,
|
||||
projectMembershipDAL,
|
||||
groupProjectDAL,
|
||||
@@ -54,8 +52,11 @@ export const userServiceFactory = ({
|
||||
permissionService
|
||||
}: TUserServiceFactoryDep) => {
|
||||
const sendEmailVerificationCode = async (username: string) => {
|
||||
const user = await userDAL.findOne({ username });
|
||||
// akhilmhdh: case sensitive email resolution
|
||||
const users = await userDAL.findUserByUsername(username);
|
||||
const user = users?.length > 1 ? users.find((el) => el.username === username) : users?.[0];
|
||||
if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` });
|
||||
|
||||
if (!user.email)
|
||||
throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" });
|
||||
if (user.isEmailVerified)
|
||||
@@ -77,7 +78,10 @@ export const userServiceFactory = ({
|
||||
};
|
||||
|
||||
const verifyEmailVerificationCode = async (username: string, code: string) => {
|
||||
const user = await userDAL.findOne({ username });
|
||||
// akhilmhdh: case sensitive email resolution
|
||||
const usersByusername = await userDAL.findUserByUsername(username);
|
||||
const user =
|
||||
usersByusername?.length > 1 ? usersByusername.find((el) => el.username === username) : usersByusername?.[0];
|
||||
if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` });
|
||||
if (!user.email)
|
||||
throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" });
|
||||
@@ -90,84 +94,8 @@ export const userServiceFactory = ({
|
||||
code
|
||||
});
|
||||
|
||||
const { email } = user;
|
||||
|
||||
await userDAL.transaction(async (tx) => {
|
||||
await userDAL.updateById(
|
||||
user.id,
|
||||
{
|
||||
isEmailVerified: true
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
// check if there are verified users with the same email.
|
||||
const users = await userDAL.find(
|
||||
{
|
||||
email,
|
||||
isEmailVerified: true
|
||||
},
|
||||
{ tx }
|
||||
);
|
||||
|
||||
if (users.length > 1) {
|
||||
// merge users
|
||||
const mergeUser = users.find((u) => u.id !== user.id);
|
||||
if (!mergeUser) throw new NotFoundError({ name: "Failed to find merge user" });
|
||||
|
||||
const mergeUserOrgMembershipSet = new Set(
|
||||
(await orgMembershipDAL.find({ userId: mergeUser.id }, { tx })).map((m) => m.orgId)
|
||||
);
|
||||
const myOrgMemberships = (await orgMembershipDAL.find({ userId: user.id }, { tx })).filter(
|
||||
(m) => !mergeUserOrgMembershipSet.has(m.orgId)
|
||||
);
|
||||
|
||||
const userAliases = await userAliasDAL.find(
|
||||
{
|
||||
userId: user.id
|
||||
},
|
||||
{ tx }
|
||||
);
|
||||
await userDAL.deleteById(user.id, tx);
|
||||
|
||||
if (myOrgMemberships.length) {
|
||||
await orgMembershipDAL.insertMany(
|
||||
myOrgMemberships.map((orgMembership) => ({
|
||||
...orgMembership,
|
||||
userId: mergeUser.id
|
||||
})),
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
if (userAliases.length) {
|
||||
await userAliasDAL.insertMany(
|
||||
userAliases.map((userAlias) => ({
|
||||
...userAlias,
|
||||
userId: mergeUser.id
|
||||
})),
|
||||
tx
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await userDAL.delete(
|
||||
{
|
||||
email,
|
||||
isAccepted: false,
|
||||
isEmailVerified: false
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
// update current user's username to [email]
|
||||
await userDAL.updateById(
|
||||
user.id,
|
||||
{
|
||||
username: email
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
await userDAL.updateById(user.id, {
|
||||
isEmailVerified: true
|
||||
});
|
||||
};
|
||||
|
||||
@@ -212,6 +140,23 @@ export const userServiceFactory = ({
|
||||
return updatedUser;
|
||||
};
|
||||
|
||||
const getAllMyAccounts = async (email: string, userId: string) => {
|
||||
const users = await userDAL.findAllMyAccounts(email);
|
||||
return users?.map((el) => ({ ...el, isMyAccount: el.id === userId }));
|
||||
};
|
||||
|
||||
const removeMyDuplicateAccounts = async (email: string, userId: string) => {
|
||||
const users = await userDAL.find({ email });
|
||||
const duplicatedAccounts = users?.filter((el) => el.id !== userId);
|
||||
const myAccount = users?.find((el) => el.id === userId);
|
||||
if (duplicatedAccounts.length && myAccount) {
|
||||
await userDAL.transaction(async (tx) => {
|
||||
await userDAL.delete({ $in: { id: duplicatedAccounts?.map((el) => el.id) } }, tx);
|
||||
await userDAL.updateById(userId, { username: (myAccount.email || myAccount.username).toLowerCase() }, tx);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getMe = async (userId: string) => {
|
||||
const user = await userDAL.findUserEncKeyByUserId(userId);
|
||||
if (!user) throw new NotFoundError({ message: `User with ID '${userId}' not found`, name: "GetMe" });
|
||||
@@ -313,9 +258,11 @@ export const userServiceFactory = ({
|
||||
};
|
||||
|
||||
const listUserGroups = async ({ username, actorOrgId, actor, actorId, actorAuthMethod }: TListUserGroupsDTO) => {
|
||||
const user = await userDAL.findOne({
|
||||
username
|
||||
});
|
||||
// akhilmhdh: case sensitive email resolution
|
||||
const usersByusername = await userDAL.findUserByUsername(username);
|
||||
const user =
|
||||
usersByusername?.length > 1 ? usersByusername.find((el) => el.username === username) : usersByusername?.[0];
|
||||
if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` });
|
||||
|
||||
// This makes it so the user can always read information about themselves, but no one else if they don't have the Members Read permission.
|
||||
if (user.id !== actorId) {
|
||||
@@ -346,7 +293,9 @@ export const userServiceFactory = ({
|
||||
getUserAction,
|
||||
unlockUser,
|
||||
getUserPrivateKey,
|
||||
getAllMyAccounts,
|
||||
getUserProjectFavorites,
|
||||
removeMyDuplicateAccounts,
|
||||
updateUserProjectFavorites
|
||||
};
|
||||
};
|
||||
|
@@ -130,7 +130,7 @@ The CLI is designed for a variety of secret management applications ranging from
|
||||
export INFISICAL_CUSTOM_HEADERS="Access-Client-Id=your-client-id Access-Client-Secret=your-client-secret"
|
||||
|
||||
# Execute Infisical commands after setting the environment variable
|
||||
infisical secrets ls
|
||||
infisical secrets
|
||||
```
|
||||
|
||||
This functionality enables secure interaction with Infisical instances that require specific authentication headers.
|
||||
|
@@ -28,7 +28,7 @@ description: "Learn how to automatically rotate LDAP passwords."
|
||||
3. Select the **LDAP Connection** to use and configure the rotation behavior. Then click **Next**.
|
||||

|
||||
|
||||
- **LDAP Connection** - the connection that will perform the rotation of the configured DN's password.
|
||||
- **LDAP Connection** - the connection that will perform the rotation of the configured principal's password.
|
||||
<Note>
|
||||
LDAP Password Rotations require an LDAP Connection that uses ldaps:// protocol.
|
||||
</Note>
|
||||
@@ -40,13 +40,20 @@ description: "Learn how to automatically rotate LDAP passwords."
|
||||
</Note>
|
||||
|
||||
|
||||
4. Specify the Distinguished Name (DN) of the principal whose password you want to rotate and configure the password requirements. Then click **Next**.
|
||||
4. Configure the required Parameters for your rotation. Then click **Next**.
|
||||

|
||||
|
||||
- **Rotation Method** - The method to use when rotating the target principal's password.
|
||||
- **Connection Principal** - Infisical will use the LDAP Connection's binding principal to rotate the target principal's password.
|
||||
- **Target Principal** - Infisical will bind with the target Principal to rotate their own password.
|
||||
- **DN/UPN** - The Distinguished Name (DN), or User Principal Name (UPN) if supported, of the principal whose password you want to rotate.
|
||||
- **Password** - The target principal's password (if **Rotation Method** is set to **Target Principal**).
|
||||
- **Password Requirements** - The constraints to apply when generating new passwords.
|
||||
|
||||
5. Specify the secret names that the client credentials should be mapped to. Then click **Next**.
|
||||

|
||||
|
||||
- **DN** - the name of the secret that the principal's Distinguished Name (DN) will be mapped to.
|
||||
- **DN/UPN** - the name of the secret that the principal's Distinguished Name (DN) or User Principal Name (UPN) will be mapped to.
|
||||
- **Password** - the name of the secret that the rotated password will be mapped to.
|
||||
|
||||
6. Give your rotation a name and description (optional). Then click **Next**.
|
||||
@@ -85,6 +92,7 @@ description: "Learn how to automatically rotate LDAP passwords."
|
||||
"minutes": 0
|
||||
},
|
||||
"parameters": {
|
||||
"rotationMethod": "connection-principal",
|
||||
"dn": "CN=John,CN=Users,DC=example,DC=com",
|
||||
"passwordRequirements": {
|
||||
"length": 48,
|
||||
@@ -154,6 +162,7 @@ description: "Learn how to automatically rotate LDAP passwords."
|
||||
"lastRotationMessage": null,
|
||||
"type": "ldap-password",
|
||||
"parameters": {
|
||||
"rotationMethod": "connection-principal",
|
||||
"dn": "CN=John,CN=Users,DC=example,DC=com",
|
||||
"passwordRequirements": {
|
||||
"length": 48,
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 758 KiB After Width: | Height: | Size: 778 KiB |
Binary file not shown.
Before Width: | Height: | Size: 782 KiB After Width: | Height: | Size: 791 KiB |
@@ -10,7 +10,7 @@ Infisical supports the use of [Simple Binding](https://ldap.com/the-ldap-bind-op
|
||||
You will need the following information to establish an LDAP connection:
|
||||
|
||||
- **LDAP URL** - The LDAP/LDAPS URL to connect to (e.g., ldap://domain-or-ip:389 or ldaps://domain-or-ip:636)
|
||||
- **Binding DN** - The Distinguished Name (DN) of the principal to bind with (e.g., 'CN=John,CN=Users,DC=example,DC=com')
|
||||
- **Binding DN/UPN** - The Distinguished Name (DN), or User Principal Name (UPN) if supported, of the principal to bind with (e.g., 'CN=John,CN=Users,DC=example,DC=com')
|
||||
- **Binding Password** - The password to bind with for authentication
|
||||
- **CA Certificate** - The SSL certificate (PEM format) to use for secure connection when using ldaps:// with a self-signed certificate
|
||||
|
||||
|
@@ -10,8 +10,10 @@ We value reports that help identify vulnerabilities that affect the integrity of
|
||||
### How to Report
|
||||
|
||||
- Send reports to **security@infisical.com** with clear steps to reproduce, impact, and (if possible) a proof-of-concept.
|
||||
- We will acknowledge receipt within 3 business days.
|
||||
- We will acknowledge receipt within 3 business days for reports that are clearly written, technically sound, and plausibly within scope.
|
||||
- We'll provide an initial assessment or next steps within 5 business days.
|
||||
- **Please note**: We do not respond to spam, auto generated reports, inaccurate claims, or submissions that are clearly out of scope.
|
||||
|
||||
|
||||
### What's in Scope?
|
||||
|
||||
|
@@ -178,12 +178,13 @@ Supports conditions and permission inversion
|
||||
|
||||
#### Subject: `secret-approval`
|
||||
|
||||
| Action | Description |
|
||||
| -------- | ----------------------------------- |
|
||||
| `read` | View approval policies and requests |
|
||||
| `create` | Create new approval policies |
|
||||
| `edit` | Modify approval policies |
|
||||
| `delete` | Remove approval policies |
|
||||
| Action | Description |
|
||||
| --------------------- | ---------------------------------------------------------------------------- |
|
||||
| `read` | View approval policies and requests |
|
||||
| `create` | Create new approval policies |
|
||||
| `edit` | Modify approval policies |
|
||||
| `delete` | Remove approval policies |
|
||||
| `allow-change-bypass` | Allow request creators to bypass policy in break-glass situations |
|
||||
|
||||
#### Subject: `secret-rotation`
|
||||
|
||||
|
@@ -32,7 +32,7 @@ const formSchema = z.object({
|
||||
environments: z
|
||||
.object({
|
||||
name: z.string().trim().min(1),
|
||||
slug: slugSchema({ min: 1, max: 32 })
|
||||
slug: slugSchema({ min: 1, max: 64 })
|
||||
})
|
||||
.array()
|
||||
.nullish()
|
||||
|
@@ -21,7 +21,7 @@ import {
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: slugSchema({ min: 1, max: 32, field: "Name" }),
|
||||
name: slugSchema({ min: 1, max: 64, field: "Name" }),
|
||||
description: z.string().max(500).optional()
|
||||
});
|
||||
|
||||
|
@@ -18,9 +18,7 @@ export const ViewLdapPasswordRotationGeneratedCredentials = ({
|
||||
<ViewRotationGeneratedCredentialsDisplay
|
||||
activeCredentials={
|
||||
<>
|
||||
<CredentialDisplay label="Distinguished Name (DN)">
|
||||
{activeCredentials?.dn}
|
||||
</CredentialDisplay>
|
||||
<CredentialDisplay label="DN/UPN">{activeCredentials?.dn}</CredentialDisplay>
|
||||
<CredentialDisplay isSensitive label="Password">
|
||||
{activeCredentials?.password}
|
||||
</CredentialDisplay>
|
||||
@@ -28,9 +26,7 @@ export const ViewLdapPasswordRotationGeneratedCredentials = ({
|
||||
}
|
||||
inactiveCredentials={
|
||||
<>
|
||||
<CredentialDisplay label="Distinguished Name (DN)">
|
||||
{inactiveCredentials?.dn}
|
||||
</CredentialDisplay>
|
||||
<CredentialDisplay label="DN/UPN">{inactiveCredentials?.dn}</CredentialDisplay>
|
||||
<CredentialDisplay isSensitive label="Password">
|
||||
{inactiveCredentials?.password}
|
||||
</CredentialDisplay>
|
||||
|
@@ -48,7 +48,8 @@ const FORM_TABS: { name: string; key: string; fields: (keyof TSecretRotationV2Fo
|
||||
"rotateAtUtc"
|
||||
]
|
||||
},
|
||||
{ name: "Parameters", key: "parameters", fields: ["parameters"] },
|
||||
// @ts-expect-error temporary parameters aren't present on all forms
|
||||
{ name: "Parameters", key: "parameters", fields: ["parameters", "temporaryParameters"] },
|
||||
{ name: "Mappings", key: "secretsMapping", fields: ["secretsMapping"] },
|
||||
{ name: "Details", key: "details", fields: ["name", "description"] },
|
||||
{ name: "Review", key: "review", fields: [] }
|
||||
@@ -75,7 +76,7 @@ export const SecretRotationV2Form = ({
|
||||
const { rotationOption } = useSecretRotationV2Option(type);
|
||||
|
||||
const formMethods = useForm<TSecretRotationV2Form>({
|
||||
resolver: zodResolver(SecretRotationV2FormSchema),
|
||||
resolver: zodResolver(SecretRotationV2FormSchema(Boolean(secretRotation))),
|
||||
defaultValues: secretRotation
|
||||
? {
|
||||
...secretRotation,
|
||||
|
@@ -2,40 +2,135 @@ import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas";
|
||||
import { DEFAULT_PASSWORD_REQUIREMENTS } from "@app/components/secret-rotations-v2/forms/schemas/shared";
|
||||
import { FormControl, Input } from "@app/components/v2";
|
||||
import { FormControl, Input, Select, SelectItem } from "@app/components/v2";
|
||||
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
|
||||
import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation";
|
||||
|
||||
export const LdapPasswordRotationParametersFields = () => {
|
||||
const { control } = useFormContext<
|
||||
const { control, watch, setValue } = useFormContext<
|
||||
TSecretRotationV2Form & {
|
||||
type: SecretRotation.LdapPassword;
|
||||
}
|
||||
>();
|
||||
|
||||
const [id, rotationMethod] = watch(["id", "parameters.rotationMethod"]);
|
||||
const isUpdate = Boolean(id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controller
|
||||
name="parameters.dn"
|
||||
name="parameters.rotationMethod"
|
||||
control={control}
|
||||
defaultValue={LdapPasswordRotationMethod.ConnectionPrincipal}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error)}
|
||||
tooltipText={
|
||||
<>
|
||||
<span>Determines how the rotation will be performed:</span>
|
||||
<ul className="ml-4 mt-2 flex list-disc flex-col gap-2">
|
||||
<li>
|
||||
<span className="font-medium">Connection Principal</span> - The Connection
|
||||
principal will rotate the target principal's password.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">Target Principal</span> - The target principal
|
||||
will rotate their own password.
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
}
|
||||
tooltipClassName="max-w-sm"
|
||||
errorText={error?.message}
|
||||
label="Distinguished Name (DN)"
|
||||
isError={Boolean(error?.message)}
|
||||
label="Rotation Method"
|
||||
helperText={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
isUpdate
|
||||
? "Cannot be updated."
|
||||
: value === LdapPasswordRotationMethod.ConnectionPrincipal
|
||||
? "The connection principal will rotate the target principal's password"
|
||||
: "The target principal will rotate their own password"
|
||||
}
|
||||
>
|
||||
<Input
|
||||
<Select
|
||||
isDisabled={isUpdate}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="CN=John,OU=Users,DC=example,DC=com"
|
||||
/>
|
||||
onValueChange={(val) => {
|
||||
setValue(
|
||||
"temporaryParameters",
|
||||
val === LdapPasswordRotationMethod.TargetPrincipal
|
||||
? {
|
||||
password: ""
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
onChange(val);
|
||||
}}
|
||||
className="w-full border border-mineshaft-500 capitalize"
|
||||
position="popper"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
>
|
||||
{Object.values(LdapPasswordRotationMethod).map((method) => {
|
||||
return (
|
||||
<SelectItem value={method} className="capitalize" key={method}>
|
||||
{method.replace("-", " ")}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<Controller
|
||||
name="parameters.dn"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="flex-1"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
label="Target Principal's DN/UPN"
|
||||
tooltipText="The DN/UPN of the principal that you want to perform password rotation on."
|
||||
tooltipClassName="max-w-sm"
|
||||
helperText={isUpdate ? "Cannot be updated." : undefined}
|
||||
>
|
||||
<Input
|
||||
isDisabled={isUpdate}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="CN=John,OU=Users,DC=example,DC=com"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{rotationMethod === LdapPasswordRotationMethod.TargetPrincipal && !isUpdate && (
|
||||
<Controller
|
||||
name="temporaryParameters.password"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="flex-1"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
label="Target Principal's Password"
|
||||
>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
type="password"
|
||||
placeholder="****************"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="w-full border-b border-mineshaft-600">
|
||||
<span className="text-sm text-mineshaft-300">Password Requirements</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 rounded border border-mineshaft-600 bg-mineshaft-700 px-3 py-2">
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1 rounded border border-mineshaft-600 bg-mineshaft-700 px-3 pt-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="parameters.passwordRequirements.length"
|
||||
@@ -45,7 +140,7 @@ export const LdapPasswordRotationParametersFields = () => {
|
||||
label="Password Length"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="The length of the password to generate"
|
||||
tooltipText="The length of the password to generate"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -67,7 +162,7 @@ export const LdapPasswordRotationParametersFields = () => {
|
||||
label="Digit Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of digits"
|
||||
tooltipText="Minimum number of digits"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -88,7 +183,7 @@ export const LdapPasswordRotationParametersFields = () => {
|
||||
label="Lowercase Character Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of lowercase characters"
|
||||
tooltipText="Minimum number of lowercase characters"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -109,7 +204,7 @@ export const LdapPasswordRotationParametersFields = () => {
|
||||
label="Uppercase Character Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of uppercase characters"
|
||||
tooltipText="Minimum number of uppercase characters"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -130,7 +225,7 @@ export const LdapPasswordRotationParametersFields = () => {
|
||||
label="Symbol Count"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Minimum number of symbols"
|
||||
tooltipText="Minimum number of symbols"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -151,7 +246,7 @@ export const LdapPasswordRotationParametersFields = () => {
|
||||
label="Allowed Symbols"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
helperText="Symbols to use in generated password"
|
||||
tooltipText="Symbols to use in generated password"
|
||||
>
|
||||
<Input
|
||||
placeholder="-_.~!*"
|
||||
|
@@ -15,13 +15,35 @@ export const LdapPasswordRotationReviewFields = () => {
|
||||
|
||||
const [parameters, { dn, password }] = watch(["parameters", "secretsMapping"]);
|
||||
|
||||
const { passwordRequirements } = parameters;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SecretRotationReviewSection label="Parameters">
|
||||
<GenericFieldLabel label="Distinguished Name (DN)">{parameters.dn}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="DN/UPN">{parameters.dn}</GenericFieldLabel>
|
||||
</SecretRotationReviewSection>
|
||||
{passwordRequirements && (
|
||||
<SecretRotationReviewSection label="Password Requirements">
|
||||
<GenericFieldLabel label="Length">{passwordRequirements.length}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Minimum Digits">
|
||||
{passwordRequirements.required.digits}
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Minimum Lowercase Characters">
|
||||
{passwordRequirements.required.lowercase}
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Minimum Uppercase Characters">
|
||||
{passwordRequirements.required.uppercase}
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Minimum Symbols">
|
||||
{passwordRequirements.required.symbols}
|
||||
</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Allowed Symbols">
|
||||
{passwordRequirements.allowedSymbols}
|
||||
</GenericFieldLabel>
|
||||
</SecretRotationReviewSection>
|
||||
)}
|
||||
<SecretRotationReviewSection label="Secrets Mapping">
|
||||
<GenericFieldLabel label="Distinguished Name (DN)">{dn}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="DN/UPN">{dn}</GenericFieldLabel>
|
||||
<GenericFieldLabel label="Password">{password}</GenericFieldLabel>
|
||||
</SecretRotationReviewSection>
|
||||
</>
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
label: "Parameters" | "Secrets Mapping";
|
||||
label: "Parameters" | "Secrets Mapping" | "Password Requirements";
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
|
@@ -17,7 +17,7 @@ export const LdapPasswordRotationSecretsMappingFields = () => {
|
||||
|
||||
const items = [
|
||||
{
|
||||
name: "DN",
|
||||
name: "DN/UPN",
|
||||
input: (
|
||||
<Controller
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
|
@@ -6,16 +6,36 @@ import { AzureClientSecretRotationSchema } from "@app/components/secret-rotation
|
||||
import { LdapPasswordRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema";
|
||||
import { MsSqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema";
|
||||
import { PostgresCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema";
|
||||
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
|
||||
import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation";
|
||||
|
||||
const SecretRotationUnionSchema = z.discriminatedUnion("type", [
|
||||
Auth0ClientSecretRotationSchema,
|
||||
AzureClientSecretRotationSchema,
|
||||
PostgresCredentialsRotationSchema,
|
||||
MsSqlCredentialsRotationSchema,
|
||||
LdapPasswordRotationSchema,
|
||||
AwsIamUserSecretRotationSchema
|
||||
]);
|
||||
export const SecretRotationV2FormSchema = (isUpdate: boolean) =>
|
||||
z
|
||||
.intersection(
|
||||
z.discriminatedUnion("type", [
|
||||
Auth0ClientSecretRotationSchema,
|
||||
AzureClientSecretRotationSchema,
|
||||
PostgresCredentialsRotationSchema,
|
||||
MsSqlCredentialsRotationSchema,
|
||||
LdapPasswordRotationSchema,
|
||||
AwsIamUserSecretRotationSchema
|
||||
]),
|
||||
z.object({ id: z.string().optional() })
|
||||
)
|
||||
.superRefine((val, ctx) => {
|
||||
if (val.type !== SecretRotation.LdapPassword || isUpdate) return;
|
||||
|
||||
export const SecretRotationV2FormSchema = SecretRotationUnionSchema;
|
||||
// this has to go on union or breaks discrimination
|
||||
if (
|
||||
val.parameters.rotationMethod === LdapPasswordRotationMethod.TargetPrincipal &&
|
||||
!val.temporaryParameters?.password
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Password required",
|
||||
path: ["temporaryParameters", "password"]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type TSecretRotationV2Form = z.infer<typeof SecretRotationV2FormSchema>;
|
||||
export type TSecretRotationV2Form = z.infer<ReturnType<typeof SecretRotationV2FormSchema>>;
|
||||
|
@@ -2,8 +2,9 @@ import { z } from "zod";
|
||||
|
||||
import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema";
|
||||
import { PasswordRequirementsSchema } from "@app/components/secret-rotations-v2/forms/schemas/shared";
|
||||
import { DistinguishedNameRegex } from "@app/helpers/string";
|
||||
import { DistinguishedNameRegex, UserPrincipalNameRegex } from "@app/helpers/string";
|
||||
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
|
||||
import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/types/ldap-password-rotation";
|
||||
|
||||
export const LdapPasswordRotationSchema = z
|
||||
.object({
|
||||
@@ -12,13 +13,24 @@ export const LdapPasswordRotationSchema = z
|
||||
dn: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(DistinguishedNameRegex, "Invalid Distinguished Name format")
|
||||
.min(1, "Distinguished Name (DN) required"),
|
||||
passwordRequirements: PasswordRequirementsSchema.optional()
|
||||
.min(1, "DN/UPN required")
|
||||
.refine(
|
||||
(value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value),
|
||||
{
|
||||
message: "Invalid DN/UPN format"
|
||||
}
|
||||
),
|
||||
passwordRequirements: PasswordRequirementsSchema.optional(),
|
||||
rotationMethod: z.nativeEnum(LdapPasswordRotationMethod).optional()
|
||||
}),
|
||||
secretsMapping: z.object({
|
||||
dn: z.string().trim().min(1, "Distinguished Name (DN) required"),
|
||||
dn: z.string().trim().min(1, "DN/UPN required"),
|
||||
password: z.string().trim().min(1, "Password required")
|
||||
})
|
||||
}),
|
||||
temporaryParameters: z
|
||||
.object({
|
||||
password: z.string().min(1, "Password required")
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.merge(BaseSecretRotationSchema);
|
||||
|
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export type TPasswordRequirements = z.infer<typeof PasswordRequirementsSchema>;
|
||||
|
||||
export const PasswordRequirementsSchema = z
|
||||
.object({
|
||||
length: z
|
||||
|
@@ -51,6 +51,7 @@ type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange" | "val
|
||||
isVisible?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
isDisabled?: boolean;
|
||||
canEditButNotView?: boolean;
|
||||
secretPath?: string;
|
||||
environment?: string;
|
||||
containerClassName?: string;
|
||||
@@ -70,6 +71,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
containerClassName,
|
||||
secretPath: propSecretPath,
|
||||
environment: propEnvironment,
|
||||
canEditButNotView,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
@@ -273,6 +275,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
<Popover.Trigger asChild>
|
||||
<SecretInput
|
||||
{...props}
|
||||
canEditButNotView={canEditButNotView}
|
||||
ref={handleRef}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={value}
|
||||
|
@@ -3,6 +3,7 @@ import { forwardRef, TextareaHTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem";
|
||||
|
||||
const REGEX = /(\${([a-zA-Z0-9-_.]+)})/g;
|
||||
const replaceContentWithDot = (str: string) => {
|
||||
@@ -51,6 +52,7 @@ type Props = TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||
isReadOnly?: boolean;
|
||||
isDisabled?: boolean;
|
||||
containerClassName?: string;
|
||||
canEditButNotView?: boolean;
|
||||
};
|
||||
|
||||
const commonClassName = "font-mono text-sm caret-white border-none outline-none w-full break-all";
|
||||
@@ -66,6 +68,7 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
isDisabled,
|
||||
isReadOnly,
|
||||
onFocus,
|
||||
canEditButNotView,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
@@ -93,7 +96,15 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
onFocus={(evt) => {
|
||||
onFocus?.(evt);
|
||||
setIsSecretFocused.on();
|
||||
evt.currentTarget.select();
|
||||
if (canEditButNotView && value === HIDDEN_SECRET_VALUE) {
|
||||
evt.currentTarget.select();
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (canEditButNotView && value === HIDDEN_SECRET_VALUE) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.select();
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
spellCheck={false}
|
||||
|
@@ -2,6 +2,7 @@ export { useProjectPermission } from "./ProjectPermissionContext";
|
||||
export type { ProjectPermissionSet, TProjectPermission } from "./types";
|
||||
export {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionApprovalActions,
|
||||
ProjectPermissionCertificateActions,
|
||||
ProjectPermissionCmekActions,
|
||||
ProjectPermissionDynamicSecretActions,
|
||||
|
@@ -24,6 +24,14 @@ export enum ProjectPermissionSecretActions {
|
||||
Delete = "delete"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionApprovalActions {
|
||||
Read = "read",
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
Delete = "delete",
|
||||
AllowChangeBypass = "allow-change-bypass"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionDynamicSecretActions {
|
||||
ReadRootCredential = "read-root-credential",
|
||||
CreateRootCredential = "create-root-credential",
|
||||
@@ -285,7 +293,7 @@ export type ProjectPermissionSet =
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.IpAllowList]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Settings]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
|
||||
| [ProjectPermissionApprovalActions, ProjectPermissionSub.SecretApproval]
|
||||
| [
|
||||
ProjectPermissionIdentityActions,
|
||||
(
|
||||
|
@@ -10,6 +10,7 @@ export {
|
||||
export type { TProjectPermission } from "./ProjectPermissionContext";
|
||||
export {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionApprovalActions,
|
||||
ProjectPermissionCertificateActions,
|
||||
ProjectPermissionCmekActions,
|
||||
ProjectPermissionDynamicSecretActions,
|
||||
|
@@ -15,3 +15,5 @@ export const isValidPath = (val: string): boolean => {
|
||||
|
||||
export const DistinguishedNameRegex =
|
||||
/^(?:(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*)(?:,(?:[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)(?:(?:\\+[a-zA-Z0-9]+=[^,+="<>#;\\\\]+)*))*)?$/;
|
||||
|
||||
export const UserPrincipalNameRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
|
73
frontend/src/helpers/userTablePreferences.ts
Normal file
73
frontend/src/helpers/userTablePreferences.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
const TABLE_PREFERENCES_KEY = "userTablePreferences";
|
||||
|
||||
export enum PreferenceKey {
|
||||
PerPage = "perPage"
|
||||
}
|
||||
|
||||
interface TableSpecificPreferences {
|
||||
[preferenceKey: string]: any;
|
||||
}
|
||||
|
||||
interface UserTablePreferences {
|
||||
[tableName: string]: TableSpecificPreferences;
|
||||
}
|
||||
|
||||
// Retrieves all table preferences from localStorage
|
||||
const getAllTablePreferences = (): UserTablePreferences => {
|
||||
try {
|
||||
const preferencesString = localStorage.getItem(TABLE_PREFERENCES_KEY);
|
||||
if (preferencesString) {
|
||||
return JSON.parse(preferencesString) as UserTablePreferences;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error reading user table preferences from localStorage:", error);
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
// Saves all table preferences to localStorage
|
||||
const saveAllTablePreferences = (preferences: UserTablePreferences): void => {
|
||||
try {
|
||||
localStorage.setItem(TABLE_PREFERENCES_KEY, JSON.stringify(preferences));
|
||||
} catch (error) {
|
||||
console.error("Error saving user table preferences to localStorage:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Retrieves a specific preference for a given table
|
||||
export const getUserTablePreference = <T>(
|
||||
tableName: string,
|
||||
preferenceKey: PreferenceKey,
|
||||
defaultValue: T
|
||||
): T => {
|
||||
const preferences = getAllTablePreferences();
|
||||
if (
|
||||
preferences &&
|
||||
typeof preferences === "object" &&
|
||||
tableName in preferences &&
|
||||
preferenceKey in preferences[tableName]
|
||||
) {
|
||||
const value = preferences[tableName][preferenceKey];
|
||||
|
||||
if (value !== undefined && value !== null) {
|
||||
return value as T;
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
// Sets a specific preference for a given table and saves it to localStorage
|
||||
export const setUserTablePreference = (
|
||||
tableName: string,
|
||||
preferenceKey: PreferenceKey,
|
||||
value: any
|
||||
): void => {
|
||||
const preferences = getAllTablePreferences();
|
||||
|
||||
if (!preferences[tableName]) {
|
||||
preferences[tableName] = {};
|
||||
}
|
||||
|
||||
preferences[tableName][preferenceKey] = value;
|
||||
saveAllTablePreferences(preferences);
|
||||
};
|
@@ -1,3 +1,4 @@
|
||||
import { TPasswordRequirements } from "@app/components/secret-rotations-v2/forms/schemas/shared";
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
import { SecretRotation } from "@app/hooks/api/secretRotationsV2";
|
||||
import {
|
||||
@@ -5,10 +6,17 @@ import {
|
||||
TSecretRotationV2GeneratedCredentialsResponseBase
|
||||
} from "@app/hooks/api/secretRotationsV2/types/shared";
|
||||
|
||||
export enum LdapPasswordRotationMethod {
|
||||
ConnectionPrincipal = "connection-principal",
|
||||
TargetPrincipal = "target-principal"
|
||||
}
|
||||
|
||||
export type TLdapPasswordRotation = TSecretRotationV2Base & {
|
||||
type: SecretRotation.LdapPassword;
|
||||
parameters: {
|
||||
dn: string;
|
||||
rotationMethod?: LdapPasswordRotationMethod;
|
||||
passwordRequirements?: TPasswordRequirements;
|
||||
};
|
||||
secretsMapping: {
|
||||
dn: string;
|
||||
|
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
useAddUserToWsE2EE,
|
||||
useAddUserToWsNonE2EE,
|
||||
useRemoveMyDuplicateAccounts,
|
||||
useRevokeMySessionById,
|
||||
useSendEmailVerificationCode,
|
||||
useVerifyEmailVerificationCode
|
||||
@@ -14,6 +15,7 @@ export {
|
||||
useDeleteOrgMembership,
|
||||
useGetMyAPIKeys,
|
||||
useGetMyAPIKeysV2,
|
||||
useGetMyDuplicateAccount,
|
||||
useGetMyIp,
|
||||
useGetMyOrganizationProjects,
|
||||
useGetMySessions,
|
||||
|
@@ -184,3 +184,12 @@ export const useRevokeMySessionById = () => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useRemoveMyDuplicateAccounts = () => {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiRequest.post("/api/v1/user/remove-duplicate-accounts");
|
||||
return data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@@ -37,6 +37,33 @@ export const useGetUser = () =>
|
||||
queryFn: fetchUserDetails
|
||||
});
|
||||
|
||||
export const fetchUserDuplicateAccounts = async () => {
|
||||
const { data } = await apiRequest.get<{
|
||||
users: Array<
|
||||
User & {
|
||||
isMyAccount: boolean;
|
||||
organizations: { name: string; slug: string }[];
|
||||
devices: {
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
}[];
|
||||
}
|
||||
>;
|
||||
}>("/api/v1/user/duplicate-accounts");
|
||||
return data.users;
|
||||
};
|
||||
|
||||
export const useGetMyDuplicateAccount = () =>
|
||||
useQuery({
|
||||
queryKey: userKeys.getMyDuplicateAccount,
|
||||
staleTime: 60 * 1000, // 1 min in ms
|
||||
queryFn: fetchUserDuplicateAccounts,
|
||||
select: (users) => ({
|
||||
duplicateAccounts: users.filter((el) => !el.isMyAccount),
|
||||
myAccount: users?.find((el) => el.isMyAccount)
|
||||
})
|
||||
});
|
||||
|
||||
export const useDeleteMe = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
@@ -1,5 +1,6 @@
|
||||
export const userKeys = {
|
||||
getUser: ["user"] as const,
|
||||
getMyDuplicateAccount: ["user-duplicate-account"] as const,
|
||||
getPrivateKey: ["user"] as const,
|
||||
userAction: ["user-action"] as const,
|
||||
userProjectFavorites: (orgId: string) => [{ orgId }, "user-project-favorites"] as const,
|
||||
|
@@ -7,7 +7,7 @@ interface SlugSchemaInputs {
|
||||
field?: string;
|
||||
}
|
||||
|
||||
export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInputs = {}) => {
|
||||
export const slugSchema = ({ min = 1, max = 64, field = "Slug" }: SlugSchemaInputs = {}) => {
|
||||
return z
|
||||
.string()
|
||||
.trim()
|
||||
|
@@ -18,7 +18,8 @@ import { useToggle } from "@app/hooks";
|
||||
import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api";
|
||||
import { MfaMethod } from "@app/hooks/api/auth/types";
|
||||
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
|
||||
import { fetchMyPrivateKey } from "@app/hooks/api/users/queries";
|
||||
import { fetchMyPrivateKey, fetchUserDuplicateAccounts } from "@app/hooks/api/users/queries";
|
||||
import { EmailDuplicationConfirmation } from "@app/pages/auth/SelectOrgPage/EmailDuplicationConfirmation";
|
||||
|
||||
import { navigateUserToOrg, useNavigateToSelectOrganization } from "../../Login.utils";
|
||||
|
||||
@@ -40,6 +41,7 @@ export const PasswordStep = ({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [removeDuplicateLater, setRemoveDuplicateLater] = useState(true);
|
||||
const { mutateAsync: selectOrganization } = useSelectOrganization();
|
||||
const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange();
|
||||
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
|
||||
@@ -109,6 +111,13 @@ export const PasswordStep = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const userDuplicateAccount = await fetchUserDuplicateAccounts();
|
||||
const hasDuplicate = userDuplicateAccount?.length > 1;
|
||||
if (hasDuplicate) {
|
||||
setRemoveDuplicateLater(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await navigateUserToOrg(navigate, organizationId);
|
||||
};
|
||||
|
||||
@@ -306,6 +315,18 @@ export const PasswordStep = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (!removeDuplicateLater) {
|
||||
return (
|
||||
<EmailDuplicationConfirmation
|
||||
onRemoveDuplicateLater={() =>
|
||||
navigateUserToOrg(navigate, organizationId).catch(() =>
|
||||
createNotification({ text: "Failed to navigate user", type: "error" })
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasExchangedPrivateKey) {
|
||||
return (
|
||||
<div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
|
||||
|
@@ -0,0 +1,164 @@
|
||||
import { useCallback } from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, DeleteActionModal, Tooltip } from "@app/components/v2";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useGetMyDuplicateAccount,
|
||||
useLogoutUser,
|
||||
useRemoveMyDuplicateAccounts
|
||||
} from "@app/hooks/api";
|
||||
|
||||
type Props = {
|
||||
onRemoveDuplicateLater: () => void;
|
||||
};
|
||||
|
||||
export const EmailDuplicationConfirmation = ({ onRemoveDuplicateLater }: Props) => {
|
||||
const duplicateAccounts = useGetMyDuplicateAccount();
|
||||
const removeDuplicateEmails = useRemoveMyDuplicateAccounts();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const logout = useLogoutUser(true);
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["removeDuplicateConfirm"] as const);
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
console.log("Logging out...");
|
||||
await logout.mutateAsync();
|
||||
navigate({ to: "/login" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [logout, navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: t("login.title") })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
<meta property="og:image" content="/images/message.png" />
|
||||
<meta property="og:title" content={t("login.og-title") ?? ""} />
|
||||
<meta name="og:description" content={t("login.og-description") ?? ""} />
|
||||
</Helmet>
|
||||
<div className="mx-auto mt-20 w-fit max-w-2xl rounded-lg border-2 border-mineshaft-500 p-10 shadow-lg">
|
||||
<Link to="/">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<img
|
||||
src="/images/gradientLogo.svg"
|
||||
style={{
|
||||
height: "90px",
|
||||
width: "120px"
|
||||
}}
|
||||
alt="Infisical logo"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
<form className="mx-auto flex w-full flex-col items-center justify-center">
|
||||
<div className="mb-6">
|
||||
<h1 className="mb-2 bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-2xl font-medium text-transparent">
|
||||
Multiple Accounts Detected
|
||||
</h1>
|
||||
<p className="text-md mb-4 text-center text-white">
|
||||
<span className="text-slate-300">You're currently logged in as</span>{" "}
|
||||
<b>{duplicateAccounts?.data?.myAccount?.username}</b>.
|
||||
</p>
|
||||
<div className="mb-4 mt-4 flex flex-col rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5">
|
||||
<p className="mb-2 mt-1 text-sm text-bunker-300">
|
||||
We've detected multiple accounts using variations of the same email address.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 w-full border-b border-mineshaft-400 pb-1 text-sm text-mineshaft-200">
|
||||
Your other accounts
|
||||
</div>
|
||||
<div className="thin-scrollbar flex h-full max-h-60 w-full flex-col items-stretch gap-2 overflow-auto rounded-md">
|
||||
{duplicateAccounts?.data?.duplicateAccounts?.map((el) => {
|
||||
const lastSession = el.devices?.at(-1);
|
||||
return (
|
||||
<div
|
||||
key={el.id}
|
||||
className="flex items-center gap-8 rounded-md bg-mineshaft-700 px-4 py-3 text-gray-200"
|
||||
>
|
||||
<div className="group flex flex-grow flex-col">
|
||||
<div className="truncate text-sm transition-colors">{el.username}</div>
|
||||
<div className="mt-2 text-xs">
|
||||
Last logged in at {format(new Date(el.updatedAt), "Pp")}
|
||||
</div>
|
||||
<div className="mt-2 text-xs">
|
||||
Organizations: {el?.organizations?.map((i) => i.slug)?.join(",")}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Tooltip
|
||||
className="max-w-lg"
|
||||
content={
|
||||
<div className="flex flex-col space-y-1 text-sm">
|
||||
<div>IP: {lastSession?.ip || "-"}</div>
|
||||
<div>User Agent: {lastSession?.userAgent || "-"}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<div className="flex gap-6">
|
||||
<Button
|
||||
className="flex-1 flex-grow"
|
||||
isLoading={removeDuplicateEmails.isPending}
|
||||
onClick={() => handlePopUpToggle("removeDuplicateConfirm", true)}
|
||||
>
|
||||
Delete all other accounts
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() => onRemoveDuplicateLater()}
|
||||
className="flex-1 flex-grow"
|
||||
>
|
||||
Remind me later
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
isLoading={logout.isPending}
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
className="mt-4"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Change Account
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="pb-28" />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeDuplicateConfirm.isOpen}
|
||||
subTitle={`You’re currently logged in as ${duplicateAccounts?.data?.myAccount?.username}. Once you confirm, your other duplicate accounts will be permanently removed. Please make sure none of those accounts contain any production secrets, as this action cannot be undone.`}
|
||||
title="Confirmation Required"
|
||||
onChange={(isOpen) => handlePopUpToggle("removeDuplicateConfirm", isOpen)}
|
||||
deleteKey="remove"
|
||||
buttonText="Confirm"
|
||||
onDeleteApproved={() =>
|
||||
removeDuplicateEmails.mutateAsync(undefined, {
|
||||
onSuccess: () => {
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Removed duplicate accounts"
|
||||
});
|
||||
onRemoveDuplicateLater();
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -1,33 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import axios from "axios";
|
||||
import { addSeconds, formatISO } from "date-fns";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Mfa } from "@app/components/auth/Mfa";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin";
|
||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||
import { Button, Spinner } from "@app/components/v2";
|
||||
import { SessionStorageKeys } from "@app/const";
|
||||
import { OrgMembershipRole } from "@app/helpers/roles";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import {
|
||||
useGetOrganizations,
|
||||
useGetUser,
|
||||
useLogoutUser,
|
||||
useSelectOrganization
|
||||
} from "@app/hooks/api";
|
||||
import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types";
|
||||
import { getAuthToken, isLoggedIn } from "@app/hooks/api/reactQuery";
|
||||
import { Organization } from "@app/hooks/api/types";
|
||||
import { AuthMethod } from "@app/hooks/api/users/types";
|
||||
import { Spinner } from "@app/components/v2";
|
||||
import { useGetMyDuplicateAccount } from "@app/hooks/api";
|
||||
|
||||
import { navigateUserToOrg } from "../LoginPage/Login.utils";
|
||||
import { EmailDuplicationConfirmation } from "./EmailDuplicationConfirmation";
|
||||
import { SelectOrganizationSection } from "./SelectOrgSection";
|
||||
|
||||
const LoadingScreen = () => {
|
||||
return (
|
||||
@@ -39,253 +16,18 @@ const LoadingScreen = () => {
|
||||
};
|
||||
|
||||
export const SelectOrganizationPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const duplicateAccounts = useGetMyDuplicateAccount();
|
||||
const [removeDuplicateLater, setRemoveDuplicateLater] = useState(false);
|
||||
|
||||
const organizations = useGetOrganizations();
|
||||
const selectOrg = useSelectOrganization();
|
||||
const { data: user, isPending: userLoading } = useGetUser();
|
||||
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
|
||||
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
|
||||
const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true);
|
||||
|
||||
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
|
||||
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
const orgId = queryParams.get("org_id");
|
||||
const callbackPort = queryParams.get("callback_port");
|
||||
const isAdminLogin = queryParams.get("is_admin_login") === "true";
|
||||
const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId);
|
||||
|
||||
const logout = useLogoutUser(true);
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
console.log("Logging out...");
|
||||
await logout.mutateAsync();
|
||||
navigate({ to: "/login" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [logout, navigate]);
|
||||
|
||||
const handleSelectOrganization = useCallback(
|
||||
async (organization: Organization) => {
|
||||
const canBypassOrgAuth =
|
||||
organization.bypassOrgAuthEnabled &&
|
||||
organization.userRole === OrgMembershipRole.Admin &&
|
||||
isAdminLogin;
|
||||
|
||||
if (organization.authEnforced && !canBypassOrgAuth) {
|
||||
// org has an org-level auth method enabled (e.g. SAML)
|
||||
// -> logout + redirect to SAML SSO
|
||||
await logout.mutateAsync();
|
||||
let url = "";
|
||||
if (organization.orgAuthMethod === AuthMethod.OIDC) {
|
||||
url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${
|
||||
callbackPort ? `&callbackPort=${callbackPort}` : ""
|
||||
}`;
|
||||
} else {
|
||||
url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`;
|
||||
|
||||
if (callbackPort) {
|
||||
url += `?callback_port=${callbackPort}`;
|
||||
}
|
||||
}
|
||||
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
|
||||
const { token, isMfaEnabled, mfaMethod } = await selectOrg
|
||||
.mutateAsync({
|
||||
organizationId: organization.id,
|
||||
userAgent: callbackPort ? UserAgentType.CLI : undefined
|
||||
})
|
||||
.finally(() => setIsInitialOrgCheckLoading(false));
|
||||
|
||||
if (isMfaEnabled) {
|
||||
SecurityClient.setMfaToken(token);
|
||||
if (mfaMethod) {
|
||||
setRequiredMfaMethod(mfaMethod);
|
||||
}
|
||||
toggleShowMfa.on();
|
||||
setMfaSuccessCallback(() => () => handleSelectOrganization(organization));
|
||||
return;
|
||||
}
|
||||
|
||||
if (callbackPort) {
|
||||
const privateKey = localStorage.getItem("PRIVATE_KEY");
|
||||
|
||||
let error: string | null = null;
|
||||
|
||||
if (!privateKey) error = "Private key not found";
|
||||
if (!user?.email) error = "User email not found";
|
||||
if (!token) error = "No token found";
|
||||
|
||||
if (error) {
|
||||
createNotification({
|
||||
text: error,
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
JTWToken: token,
|
||||
email: user?.email,
|
||||
privateKey
|
||||
} as IsCliLoginSuccessful["loginResponse"];
|
||||
|
||||
// send request to server endpoint
|
||||
const instance = axios.create();
|
||||
await instance.post(`http://127.0.0.1:${callbackPort}/`, payload).catch(() => {
|
||||
// if error happens to communicate we set the token with an expiry in sessino storage
|
||||
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
|
||||
sessionStorage.setItem(
|
||||
SessionStorageKeys.CLI_TERMINAL_TOKEN,
|
||||
JSON.stringify({
|
||||
expiry: formatISO(addSeconds(new Date(), 30)),
|
||||
data: window.btoa(JSON.stringify(payload))
|
||||
})
|
||||
);
|
||||
});
|
||||
navigate({ to: "/cli-redirect" });
|
||||
// cli page
|
||||
} else {
|
||||
navigateUserToOrg(navigate, organization.id);
|
||||
}
|
||||
},
|
||||
[selectOrg]
|
||||
);
|
||||
|
||||
const handleCliRedirect = useCallback(() => {
|
||||
const authToken = getAuthToken();
|
||||
|
||||
if (authToken && !callbackPort) {
|
||||
const decodedJwt = jwtDecode(authToken) as any;
|
||||
|
||||
if (decodedJwt?.organizationId) {
|
||||
navigateUserToOrg(navigate, decodedJwt.organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
navigate({ to: "/login" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (callbackPort) {
|
||||
handleCliRedirect();
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (organizations.isPending || !organizations.data) return;
|
||||
|
||||
// Case: User has no organizations.
|
||||
// This can happen if the user was previously a member, but the organization was deleted or the user was removed.
|
||||
if (organizations.data.length === 0) {
|
||||
navigate({ to: "/organization/none" });
|
||||
} else if (organizations.data.length === 1) {
|
||||
if (callbackPort) {
|
||||
handleCliRedirect();
|
||||
setIsInitialOrgCheckLoading(false);
|
||||
} else {
|
||||
handleSelectOrganization(organizations.data[0]);
|
||||
}
|
||||
} else {
|
||||
setIsInitialOrgCheckLoading(false);
|
||||
}
|
||||
}, [organizations.isPending, organizations.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultSelectedOrg) {
|
||||
handleSelectOrganization(defaultSelectedOrg);
|
||||
}
|
||||
}, [defaultSelectedOrg]);
|
||||
|
||||
if (
|
||||
userLoading ||
|
||||
!user ||
|
||||
((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa)
|
||||
) {
|
||||
if (duplicateAccounts.isPending) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: t("login.title") })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
<meta property="og:image" content="/images/message.png" />
|
||||
<meta property="og:title" content={t("login.og-title") ?? ""} />
|
||||
<meta name="og:description" content={t("login.og-description") ?? ""} />
|
||||
</Helmet>
|
||||
{shouldShowMfa ? (
|
||||
<Mfa
|
||||
email={user.email as string}
|
||||
successCallback={mfaSuccessCallback}
|
||||
method={requiredMfaMethod}
|
||||
/>
|
||||
) : (
|
||||
<div className="mx-auto mt-20 w-fit rounded-lg border-2 border-mineshaft-500 p-10 shadow-lg">
|
||||
<Link to="/">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<img
|
||||
src="/images/gradientLogo.svg"
|
||||
style={{
|
||||
height: "90px",
|
||||
width: "120px"
|
||||
}}
|
||||
alt="Infisical logo"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
<form className="mx-auto flex w-full flex-col items-center justify-center">
|
||||
<div className="mb-8 space-y-2">
|
||||
<h1 className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-2xl font-medium text-transparent">
|
||||
Choose your organization
|
||||
</h1>
|
||||
if (duplicateAccounts.data?.duplicateAccounts?.length && !removeDuplicateLater) {
|
||||
return (
|
||||
<EmailDuplicationConfirmation onRemoveDuplicateLater={() => setRemoveDuplicateLater(true)} />
|
||||
);
|
||||
}
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-md text-center text-gray-500">
|
||||
You‘re currently logged in as <strong>{user.username}</strong>
|
||||
</p>
|
||||
<p className="text-md text-center text-gray-500">
|
||||
Not you?{" "}
|
||||
<Button variant="link" onClick={handleLogout} className="font-semibold">
|
||||
Change account
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 w-1/4 min-w-[21.2rem] space-y-4 rounded-md text-center md:min-w-[25.1rem] lg:w-1/4">
|
||||
{organizations.isPending ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
organizations.data?.map((org) => (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
onClick={() => handleSelectOrganization(org)}
|
||||
key={org.id}
|
||||
className="group flex cursor-pointer items-center justify-between rounded-md bg-mineshaft-700 px-4 py-3 capitalize text-gray-200 shadow-md transition-colors hover:bg-mineshaft-600"
|
||||
>
|
||||
<p className="truncate transition-colors">{org.name}</p>
|
||||
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowRight}
|
||||
className="text-gray-400 transition-all group-hover:translate-x-2 group-hover:text-primary-500"
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pb-28" />
|
||||
</div>
|
||||
);
|
||||
return <SelectOrganizationSection />;
|
||||
};
|
||||
|
304
frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx
Normal file
304
frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import axios from "axios";
|
||||
import { addSeconds, formatISO } from "date-fns";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
|
||||
import { Mfa } from "@app/components/auth/Mfa";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin";
|
||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||
import { Button, Spinner } from "@app/components/v2";
|
||||
import { SessionStorageKeys } from "@app/const";
|
||||
import { OrgMembershipRole } from "@app/helpers/roles";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import {
|
||||
useGetOrganizations,
|
||||
useGetUser,
|
||||
useLogoutUser,
|
||||
useSelectOrganization
|
||||
} from "@app/hooks/api";
|
||||
import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types";
|
||||
import { getAuthToken, isLoggedIn } from "@app/hooks/api/reactQuery";
|
||||
import { Organization } from "@app/hooks/api/types";
|
||||
import { AuthMethod } from "@app/hooks/api/users/types";
|
||||
|
||||
import { navigateUserToOrg } from "../LoginPage/Login.utils";
|
||||
|
||||
const LoadingScreen = () => {
|
||||
return (
|
||||
<div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
|
||||
<Spinner />
|
||||
<p className="text-white opacity-80">Loading, please wait</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SelectOrganizationSection = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const organizations = useGetOrganizations();
|
||||
const selectOrg = useSelectOrganization();
|
||||
const { data: user, isPending: userLoading } = useGetUser();
|
||||
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
|
||||
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
|
||||
const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true);
|
||||
|
||||
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
|
||||
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
const orgId = queryParams.get("org_id");
|
||||
const callbackPort = queryParams.get("callback_port");
|
||||
const isAdminLogin = queryParams.get("is_admin_login") === "true";
|
||||
const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId);
|
||||
|
||||
const logout = useLogoutUser(true);
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
console.log("Logging out...");
|
||||
await logout.mutateAsync();
|
||||
navigate({ to: "/login" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [logout, navigate]);
|
||||
|
||||
const handleSelectOrganization = useCallback(
|
||||
async (organization: Organization) => {
|
||||
const isUserOrgAdmin = organization.userRole === OrgMembershipRole.Admin;
|
||||
const canBypassOrgAuth = organization.bypassOrgAuthEnabled && isUserOrgAdmin && isAdminLogin;
|
||||
|
||||
if (isAdminLogin) {
|
||||
if (!organization.bypassOrgAuthEnabled) {
|
||||
createNotification({
|
||||
text: "This organization does not have bypass org auth enabled",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isUserOrgAdmin) {
|
||||
createNotification({
|
||||
text: "Only organization admins can bypass org auth",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (organization.authEnforced && !canBypassOrgAuth) {
|
||||
// org has an org-level auth method enabled (e.g. SAML)
|
||||
// -> logout + redirect to SAML SSO
|
||||
await logout.mutateAsync();
|
||||
let url = "";
|
||||
if (organization.orgAuthMethod === AuthMethod.OIDC) {
|
||||
url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${
|
||||
callbackPort ? `&callbackPort=${callbackPort}` : ""
|
||||
}`;
|
||||
} else {
|
||||
url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`;
|
||||
|
||||
if (callbackPort) {
|
||||
url += `?callback_port=${callbackPort}`;
|
||||
}
|
||||
}
|
||||
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
|
||||
const { token, isMfaEnabled, mfaMethod } = await selectOrg
|
||||
.mutateAsync({
|
||||
organizationId: organization.id,
|
||||
userAgent: callbackPort ? UserAgentType.CLI : undefined
|
||||
})
|
||||
.finally(() => setIsInitialOrgCheckLoading(false));
|
||||
|
||||
if (isMfaEnabled) {
|
||||
SecurityClient.setMfaToken(token);
|
||||
if (mfaMethod) {
|
||||
setRequiredMfaMethod(mfaMethod);
|
||||
}
|
||||
toggleShowMfa.on();
|
||||
setMfaSuccessCallback(() => () => handleSelectOrganization(organization));
|
||||
return;
|
||||
}
|
||||
|
||||
if (callbackPort) {
|
||||
const privateKey = localStorage.getItem("PRIVATE_KEY");
|
||||
|
||||
let error: string | null = null;
|
||||
|
||||
if (!privateKey) error = "Private key not found";
|
||||
if (!user?.email) error = "User email not found";
|
||||
if (!token) error = "No token found";
|
||||
|
||||
if (error) {
|
||||
createNotification({
|
||||
text: error,
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
JTWToken: token,
|
||||
email: user?.email,
|
||||
privateKey
|
||||
} as IsCliLoginSuccessful["loginResponse"];
|
||||
|
||||
// send request to server endpoint
|
||||
const instance = axios.create();
|
||||
await instance.post(`http://127.0.0.1:${callbackPort}/`, payload).catch(() => {
|
||||
// if error happens to communicate we set the token with an expiry in sessino storage
|
||||
// the cli-redirect page has logic to show this to user and ask them to paste it in terminal
|
||||
sessionStorage.setItem(
|
||||
SessionStorageKeys.CLI_TERMINAL_TOKEN,
|
||||
JSON.stringify({
|
||||
expiry: formatISO(addSeconds(new Date(), 30)),
|
||||
data: window.btoa(JSON.stringify(payload))
|
||||
})
|
||||
);
|
||||
});
|
||||
navigate({ to: "/cli-redirect" });
|
||||
// cli page
|
||||
} else {
|
||||
navigateUserToOrg(navigate, organization.id);
|
||||
}
|
||||
},
|
||||
[selectOrg]
|
||||
);
|
||||
|
||||
const handleCliRedirect = useCallback(() => {
|
||||
const authToken = getAuthToken();
|
||||
|
||||
if (authToken && !callbackPort) {
|
||||
const decodedJwt = jwtDecode(authToken) as any;
|
||||
|
||||
if (decodedJwt?.organizationId) {
|
||||
navigateUserToOrg(navigate, decodedJwt.organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
navigate({ to: "/login" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (callbackPort) {
|
||||
handleCliRedirect();
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (organizations.isPending || !organizations.data) return;
|
||||
|
||||
// Case: User has no organizations.
|
||||
// This can happen if the user was previously a member, but the organization was deleted or the user was removed.
|
||||
if (organizations.data.length === 0) {
|
||||
navigate({ to: "/organization/none" });
|
||||
} else if (organizations.data.length === 1) {
|
||||
if (callbackPort) {
|
||||
handleCliRedirect();
|
||||
setIsInitialOrgCheckLoading(false);
|
||||
} else {
|
||||
handleSelectOrganization(organizations.data[0]);
|
||||
}
|
||||
} else {
|
||||
setIsInitialOrgCheckLoading(false);
|
||||
}
|
||||
}, [organizations.isPending, organizations.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultSelectedOrg) {
|
||||
handleSelectOrganization(defaultSelectedOrg);
|
||||
}
|
||||
}, [defaultSelectedOrg]);
|
||||
|
||||
if (
|
||||
userLoading ||
|
||||
!user ||
|
||||
((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa)
|
||||
) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
|
||||
<Helmet>
|
||||
<title>{t("common.head-title", { title: t("login.title") })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
<meta property="og:image" content="/images/message.png" />
|
||||
<meta property="og:title" content={t("login.og-title") ?? ""} />
|
||||
<meta name="og:description" content={t("login.og-description") ?? ""} />
|
||||
</Helmet>
|
||||
{shouldShowMfa ? (
|
||||
<Mfa
|
||||
email={user.email as string}
|
||||
successCallback={mfaSuccessCallback}
|
||||
method={requiredMfaMethod}
|
||||
/>
|
||||
) : (
|
||||
<div className="mx-auto mt-20 w-fit rounded-lg border-2 border-mineshaft-500 p-10 shadow-lg">
|
||||
<Link to="/">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<img
|
||||
src="/images/gradientLogo.svg"
|
||||
style={{
|
||||
height: "90px",
|
||||
width: "120px"
|
||||
}}
|
||||
alt="Infisical logo"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
<form className="mx-auto flex w-full flex-col items-center justify-center">
|
||||
<div className="mb-8 space-y-2">
|
||||
<h1 className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-2xl font-medium text-transparent">
|
||||
Choose your organization
|
||||
</h1>
|
||||
<div className="space-y-1">
|
||||
<p className="text-md text-center text-gray-500">
|
||||
You‘re currently logged in as <strong>{user.username}</strong>
|
||||
</p>
|
||||
<p className="text-md text-center text-gray-500">
|
||||
Not you?{" "}
|
||||
<Button variant="link" onClick={handleLogout} className="font-semibold">
|
||||
Change account
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 w-1/4 min-w-[21.2rem] space-y-4 rounded-md text-center md:min-w-[25.1rem] lg:w-1/4">
|
||||
{organizations.isPending ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
organizations.data?.map((org) => (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
onClick={() => handleSelectOrganization(org)}
|
||||
key={org.id}
|
||||
className="group flex cursor-pointer items-center justify-between rounded-md bg-mineshaft-700 px-4 py-3 capitalize text-gray-200 shadow-md transition-colors hover:bg-mineshaft-600"
|
||||
>
|
||||
<p className="truncate transition-colors">{org.name}</p>
|
||||
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowRight}
|
||||
className="text-gray-400 transition-all group-hover:translate-x-2 group-hover:text-primary-500"
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
<div className="pb-28" />
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -43,6 +43,11 @@ import {
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { useGetKmipClientsByProjectId } from "@app/hooks/api/kmip";
|
||||
@@ -71,7 +76,14 @@ export const KmipClientTable = () => {
|
||||
perPage,
|
||||
page,
|
||||
setPerPage
|
||||
} = usePagination(KmipClientOrderBy.Name);
|
||||
} = usePagination(KmipClientOrderBy.Name, {
|
||||
initPerPage: getUserTablePreference("kmipClientTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("kmipClientTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const { data, isPending, isFetching } = useGetKmipClientsByProjectId({
|
||||
projectId,
|
||||
@@ -290,7 +302,7 @@ export const KmipClientTable = () => {
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!isPending && kmipClients.length === 0 && (
|
||||
|
@@ -53,6 +53,11 @@ import {
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { kmsKeyUsageOptions } from "@app/helpers/kms";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, usePopUp, useResetPageHelper, useTimedReset } from "@app/hooks";
|
||||
import { useGetCmeksByProjectId, useUpdateCmek } from "@app/hooks/api/cmeks";
|
||||
import { CmekOrderBy, KmsKeyUsage, TCmek } from "@app/hooks/api/cmeks/types";
|
||||
@@ -100,7 +105,14 @@ export const CmekTable = () => {
|
||||
perPage,
|
||||
page,
|
||||
setPerPage
|
||||
} = usePagination(CmekOrderBy.Name);
|
||||
} = usePagination(CmekOrderBy.Name, {
|
||||
initPerPage: getUserTablePreference("cmekClientTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("cmekClientTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const { data, isPending, isFetching } = useGetCmeksByProjectId({
|
||||
projectId,
|
||||
@@ -508,7 +520,7 @@ export const CmekTable = () => {
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!isPending && keys.length === 0 && (
|
||||
|
@@ -34,6 +34,11 @@ import {
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionGroupActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useGetOrganizationGroups, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
@@ -103,7 +108,14 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
setOrderBy,
|
||||
setOrderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination<GroupsOrderBy>(GroupsOrderBy.Name, { initPerPage: 20 });
|
||||
} = usePagination<GroupsOrderBy>(GroupsOrderBy.Name, {
|
||||
initPerPage: getUserTablePreference("orgGroupsTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("orgGroupsTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
const filtered = search
|
||||
@@ -376,7 +388,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !filteredGroups?.length && (
|
||||
|
@@ -42,6 +42,11 @@ import {
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useGetOrgRoles, useSearchIdentities, useUpdateIdentity } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
@@ -76,7 +81,15 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
perPage,
|
||||
page,
|
||||
setPerPage
|
||||
} = usePagination<OrgIdentityOrderBy>(OrgIdentityOrderBy.Name);
|
||||
} = usePagination<OrgIdentityOrderBy>(OrgIdentityOrderBy.Name, {
|
||||
initPerPage: getUserTablePreference("identityTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("identityTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const [filteredRoles, setFilteredRoles] = useState<string[]>([]);
|
||||
|
||||
const organizationId = currentOrg?.id || "";
|
||||
@@ -379,7 +392,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!isPending && data && data?.identities.length === 0 && (
|
||||
|
@@ -42,6 +42,11 @@ import {
|
||||
useSubscription,
|
||||
useUser
|
||||
} from "@app/context";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import {
|
||||
useFetchServerStatus,
|
||||
@@ -170,7 +175,14 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
setOrderBy,
|
||||
setOrderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination<OrgMembersOrderBy>(OrgMembersOrderBy.Name, { initPerPage: 20 });
|
||||
} = usePagination<OrgMembersOrderBy>(OrgMembersOrderBy.Name, {
|
||||
initPerPage: getUserTablePreference("orgMembersTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("orgMembersTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const filteredUsers = useMemo(
|
||||
() =>
|
||||
@@ -513,7 +525,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!isMembersLoading && !filteredUsers?.length && (
|
||||
|
@@ -5,7 +5,7 @@ import { FormControl, Input, TextArea } from "@app/components/v2";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
export const genericAppConnectionFieldsSchema = z.object({
|
||||
name: slugSchema({ min: 1, max: 32, field: "Name" }),
|
||||
name: slugSchema({ min: 1, max: 64, field: "Name" }),
|
||||
description: z.string().trim().max(256, "Description cannot exceed 256 characters").nullish()
|
||||
});
|
||||
|
||||
|
@@ -19,7 +19,7 @@ import {
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
|
||||
import { DistinguishedNameRegex } from "@app/helpers/string";
|
||||
import { DistinguishedNameRegex, UserPrincipalNameRegex } from "@app/helpers/string";
|
||||
import {
|
||||
LdapConnectionMethod,
|
||||
LdapConnectionProvider,
|
||||
@@ -55,8 +55,13 @@ const formSchema = z.discriminatedUnion("method", [
|
||||
dn: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(DistinguishedNameRegex, "Invalid Distinguished Name format")
|
||||
.min(1, "Distinguished Name (DN) required"),
|
||||
.min(1, "DN/UPN required")
|
||||
.refine(
|
||||
(value) => DistinguishedNameRegex.test(value) || UserPrincipalNameRegex.test(value),
|
||||
{
|
||||
message: "Invalid DN/UPN format"
|
||||
}
|
||||
),
|
||||
password: z.string().trim().min(1, "Password required"),
|
||||
sslRejectUnauthorized: z.boolean(),
|
||||
sslCertificate: z
|
||||
@@ -223,7 +228,7 @@ export const LdapConnectionForm = ({ appConnection, onSubmit }: Props) => {
|
||||
<FormControl
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error?.message)}
|
||||
label="Binding Distinguished Name (DN)"
|
||||
label="Binding DN/UPN"
|
||||
>
|
||||
<Input {...field} placeholder="CN=John,OU=Users,DC=example,DC=com" />
|
||||
</FormControl>
|
||||
|
@@ -30,6 +30,11 @@ import {
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import { TAppConnection, useListAppConnections } from "@app/hooks/api/appConnections";
|
||||
import { AppConnection } from "@app/hooks/api/appConnections/enums";
|
||||
@@ -76,7 +81,14 @@ export const AppConnectionsTable = () => {
|
||||
orderBy,
|
||||
setOrderDirection,
|
||||
setOrderBy
|
||||
} = usePagination<AppConnectionsOrderBy>(AppConnectionsOrderBy.App, { initPerPage: 20 });
|
||||
} = usePagination<AppConnectionsOrderBy>(AppConnectionsOrderBy.App, {
|
||||
initPerPage: getUserTablePreference("appConnectionsTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("appConnectionsTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const filteredAppConnections = useMemo(
|
||||
() =>
|
||||
@@ -282,7 +294,7 @@ export const AppConnectionsTable = () => {
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !filteredAppConnections?.length && (
|
||||
|
@@ -1,4 +1,9 @@
|
||||
import { faCircleCheck, faCircleXmark, faFileInvoice } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faCircleCheck,
|
||||
faCircleXmark,
|
||||
faFileInvoice,
|
||||
faInfoCircle
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
@@ -10,6 +15,7 @@ import {
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
@@ -48,9 +54,26 @@ export const CurrentPlanSection = () => {
|
||||
data &&
|
||||
data?.rows?.length > 0 &&
|
||||
data.rows.map(({ name, allowed, used }) => {
|
||||
let toolTipText = null;
|
||||
if (name === "Organization identity limit") {
|
||||
toolTipText =
|
||||
"Identity count is calculated by the total number of user identities and machine identities.";
|
||||
}
|
||||
|
||||
return (
|
||||
<Tr key={`current-plan-row-${name}`} className="h-12">
|
||||
<Td>{name}</Td>
|
||||
<Td>
|
||||
{name}
|
||||
{toolTipText && (
|
||||
<Tooltip content={toolTipText}>
|
||||
<FontAwesomeIcon
|
||||
icon={faInfoCircle}
|
||||
className="relative bottom-2 left-2"
|
||||
size="xs"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Td>
|
||||
<Td>{displayCell(allowed)}</Td>
|
||||
<Td>{used}</Td>
|
||||
</Tr>
|
||||
|
@@ -25,6 +25,11 @@ import {
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionGroupActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useListGroupUsers, useOidcManageGroupMembershipsEnabled } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
@@ -57,7 +62,14 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination(GroupMembersOrderBy.Name, { initPerPage: 10 });
|
||||
} = usePagination(GroupMembersOrderBy.Name, {
|
||||
initPerPage: getUserTablePreference("groupMembersTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("groupMembersTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
|
||||
@@ -163,7 +175,7 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !filteredGroupMemberships?.length && (
|
||||
|
@@ -21,6 +21,11 @@ import {
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useGetIdentityProjectMemberships } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
@@ -53,7 +58,14 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) =>
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination(IdentityProjectsOrderBy.Name, { initPerPage: 10 });
|
||||
} = usePagination(IdentityProjectsOrderBy.Name, {
|
||||
initPerPage: getUserTablePreference("identityProjectsTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("identityProjectsTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const filteredProjectMemberships = useMemo(
|
||||
() =>
|
||||
@@ -132,7 +144,7 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) =>
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !filteredProjectMemberships?.length && (
|
||||
|
@@ -28,6 +28,11 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { getProjectHomePage } from "@app/helpers/project";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import { useRequestProjectAccess, useSearchProjects } from "@app/hooks/api";
|
||||
import { ProjectType, Workspace } from "@app/hooks/api/workspace/types";
|
||||
@@ -104,7 +109,15 @@ export const AllProjectView = ({
|
||||
limit,
|
||||
toggleOrderDirection,
|
||||
orderDirection
|
||||
} = usePagination("name", { initPerPage: 50 });
|
||||
} = usePagination("name", {
|
||||
initPerPage: getUserTablePreference("allProjectsTable", PreferenceKey.PerPage, 50)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("allProjectsTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp([
|
||||
"requestAccessConfirmation"
|
||||
] as const);
|
||||
@@ -274,7 +287,7 @@ export const AllProjectView = ({
|
||||
count={searchedProjects?.totalCount || 0}
|
||||
page={page}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!isProjectLoading && !searchedProjects?.totalCount && (
|
||||
|
@@ -19,6 +19,11 @@ import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { Button, IconButton, Input, Pagination, Skeleton, Tooltip } from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { getProjectHomePage } from "@app/helpers/project";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useGetUserWorkspaces } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
@@ -63,7 +68,15 @@ export const MyProjectView = ({
|
||||
limit,
|
||||
toggleOrderDirection,
|
||||
orderDirection
|
||||
} = usePagination(ProjectOrderBy.Name, { initPerPage: 24 });
|
||||
} = usePagination(ProjectOrderBy.Name, {
|
||||
initPerPage: getUserTablePreference("myProjectsTable", PreferenceKey.PerPage, 24)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("myProjectsTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const { data: projectFavorites, isPending: isProjectFavoritesLoading } =
|
||||
useGetUserProjectFavorites(currentOrg?.id);
|
||||
|
||||
@@ -415,7 +428,7 @@ export const MyProjectView = ({
|
||||
count={filteredWorkspaces.length}
|
||||
page={page}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{isWorkspaceEmpty && (
|
||||
|
@@ -13,6 +13,11 @@ import {
|
||||
useOrganization,
|
||||
useServerConfig
|
||||
} from "@app/context";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { withPermission } from "@app/hoc";
|
||||
import { usePagination, usePopUp } from "@app/hooks";
|
||||
import {
|
||||
@@ -28,8 +33,6 @@ import { SecretScanningFilter } from "./components/SecretScanningFilters";
|
||||
import { SecretScanningFilterFormData, secretScanningFilterFormSchema } from "./components/types";
|
||||
import { SecretScanningLogsTable } from "./components";
|
||||
|
||||
const PER_PAGE_INIT = 25;
|
||||
|
||||
export const SecretScanningPage = withPermission(
|
||||
() => {
|
||||
const queryParams = useSearch({
|
||||
@@ -49,9 +52,14 @@ export const SecretScanningPage = withPermission(
|
||||
|
||||
const { offset, limit, orderBy, setPage, perPage, page, setPerPage } = usePagination(
|
||||
SecretScanningOrderBy.CreatedAt,
|
||||
{ initPerPage: PER_PAGE_INIT }
|
||||
{ initPerPage: getUserTablePreference("secretScanningTable", PreferenceKey.PerPage, 20) }
|
||||
);
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("secretScanningTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const repositoryNames = watch("repositoryNames");
|
||||
const resolvedStatus = watch("resolved");
|
||||
|
||||
@@ -180,7 +188,7 @@ export const SecretScanningPage = withPermission(
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-8 space-y-3">
|
||||
<div className="mt-8 space-y-2">
|
||||
{integrationEnabled && (
|
||||
<div className="flex w-full items-center justify-end">
|
||||
<SecretScanningFilter
|
||||
@@ -191,18 +199,16 @@ export const SecretScanningPage = withPermission(
|
||||
</div>
|
||||
)}
|
||||
<SecretScanningLogsTable gitRisks={risksData?.risks} isPending={isPending} />
|
||||
{!isPending &&
|
||||
risksData?.totalCount !== undefined &&
|
||||
risksData.totalCount >= PER_PAGE_INIT && (
|
||||
<Pagination
|
||||
className="rounded-md"
|
||||
count={risksData.totalCount}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
|
||||
/>
|
||||
)}
|
||||
{!isPending && risksData?.totalCount !== undefined && risksData.totalCount >= 10 && (
|
||||
<Pagination
|
||||
className="rounded-md"
|
||||
count={risksData.totalCount}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
|
@@ -20,6 +20,11 @@ import {
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { OrgUser } from "@app/hooks/api/types";
|
||||
@@ -52,7 +57,14 @@ export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => {
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination(UserGroupsOrderBy.Name, { initPerPage: 10 });
|
||||
} = usePagination(UserGroupsOrderBy.Name, {
|
||||
initPerPage: getUserTablePreference("userGroupsTable", PreferenceKey.PerPage, 10)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("userGroupsTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const filteredGroupMemberships = useMemo(
|
||||
() =>
|
||||
@@ -119,7 +131,7 @@ export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => {
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !filteredGroupMemberships?.length && (
|
||||
|
@@ -22,6 +22,11 @@ import {
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useGetOrgMembershipProjectMemberships } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
@@ -54,7 +59,14 @@ export const UserProjectsTable = ({ membershipId, handlePopUpOpen }: Props) => {
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination(UserProjectsOrderBy.Name, { initPerPage: 10 });
|
||||
} = usePagination(UserProjectsOrderBy.Name, {
|
||||
initPerPage: getUserTablePreference("userProjectsTable", PreferenceKey.PerPage, 10)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("userProjectsTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const { data: projectMemberships = [], isPending } = useGetOrgMembershipProjectMemberships(
|
||||
orgId,
|
||||
@@ -136,7 +148,7 @@ export const UserProjectsTable = ({ membershipId, handlePopUpOpen }: Props) => {
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !filteredProjectMemberships?.length && (
|
||||
|
@@ -27,6 +27,11 @@ import {
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useListWorkspaceGroups } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
@@ -62,7 +67,14 @@ export const GroupTable = ({ handlePopUpOpen }: Props) => {
|
||||
orderDirection,
|
||||
orderBy,
|
||||
toggleOrderDirection
|
||||
} = usePagination(GroupsOrderBy.Name, { initPerPage: 20 });
|
||||
} = usePagination(GroupsOrderBy.Name, {
|
||||
initPerPage: getUserTablePreference("projectGroupsTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("projectGroupsTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const { data: groupMemberships = [], isPending } = useListWorkspaceGroups(
|
||||
currentWorkspace?.id || ""
|
||||
@@ -183,7 +195,7 @@ export const GroupTable = ({ handlePopUpOpen }: Props) => {
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !filteredGroupMemberships?.length && (
|
||||
|
@@ -42,6 +42,11 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { formatProjectRoleName } from "@app/helpers/roles";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
|
||||
@@ -72,7 +77,14 @@ export const IdentityTab = withProjectPermission(
|
||||
perPage,
|
||||
page,
|
||||
setPerPage
|
||||
} = usePagination(ProjectIdentityOrderBy.Name);
|
||||
} = usePagination(ProjectIdentityOrderBy.Name, {
|
||||
initPerPage: getUserTablePreference("projectIdentityTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("projectIdentityTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const workspaceId = currentWorkspace?.id ?? "";
|
||||
|
||||
@@ -403,7 +415,7 @@ export const IdentityTab = withProjectPermission(
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!isPending && data && data?.identityMemberships.length === 0 && (
|
||||
|
@@ -51,6 +51,11 @@ import {
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { formatProjectRoleName } from "@app/helpers/roles";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useGetProjectRoles, useGetWorkspaceUsers } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
@@ -101,12 +106,12 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
|
||||
setOrderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination<MembersOrderBy>(MembersOrderBy.Name, {
|
||||
initPerPage: parseInt(localStorage.getItem("PROJECT_MEMBERS_TABLE_PER_PAGE") || "20", 10)
|
||||
initPerPage: getUserTablePreference("projectMembersTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
localStorage.setItem("PROJECT_MEMBERS_TABLE_PER_PAGE", newPerPage.toString());
|
||||
setUserTablePreference("projectMembersTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const { data: members = [], isPending: isMembersLoading } = useGetWorkspaceUsers(
|
||||
|
@@ -12,6 +12,7 @@ import {
|
||||
} from "@app/context";
|
||||
import {
|
||||
PermissionConditionOperators,
|
||||
ProjectPermissionApprovalActions,
|
||||
ProjectPermissionDynamicSecretActions,
|
||||
ProjectPermissionGroupActions,
|
||||
ProjectPermissionIdentityActions,
|
||||
@@ -52,6 +53,14 @@ const SecretPolicyActionSchema = z.object({
|
||||
[ProjectPermissionSecretActions.Create]: z.boolean().optional()
|
||||
});
|
||||
|
||||
const ApprovalPolicyActionSchema = z.object({
|
||||
[ProjectPermissionApprovalActions.Read]: z.boolean().optional(),
|
||||
[ProjectPermissionApprovalActions.Edit]: z.boolean().optional(),
|
||||
[ProjectPermissionApprovalActions.Delete]: z.boolean().optional(),
|
||||
[ProjectPermissionApprovalActions.Create]: z.boolean().optional(),
|
||||
[ProjectPermissionApprovalActions.AllowChangeBypass]: z.boolean().optional()
|
||||
});
|
||||
|
||||
const CmekPolicyActionSchema = z.object({
|
||||
read: z.boolean().optional(),
|
||||
edit: z.boolean().optional(),
|
||||
@@ -261,7 +270,7 @@ export const projectRoleFormSchema = z.object({
|
||||
.array()
|
||||
.default([]),
|
||||
[ProjectPermissionSub.SshHostGroups]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.SecretApproval]: ApprovalPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Tags]: GeneralPolicyActionSchema.array().default([]),
|
||||
@@ -402,7 +411,6 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
||||
ProjectPermissionSub.PkiAlerts,
|
||||
ProjectPermissionSub.PkiCollections,
|
||||
ProjectPermissionSub.CertificateTemplates,
|
||||
ProjectPermissionSub.SecretApproval,
|
||||
ProjectPermissionSub.Tags,
|
||||
ProjectPermissionSub.SecretRotation,
|
||||
ProjectPermissionSub.Kms,
|
||||
@@ -564,6 +572,25 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === ProjectPermissionSub.SecretApproval) {
|
||||
const canCreate = action.includes(ProjectPermissionApprovalActions.Create);
|
||||
const canDelete = action.includes(ProjectPermissionApprovalActions.Delete);
|
||||
const canEdit = action.includes(ProjectPermissionApprovalActions.Edit);
|
||||
const canRead = action.includes(ProjectPermissionApprovalActions.Read);
|
||||
const canChangeBypass = action.includes(ProjectPermissionApprovalActions.AllowChangeBypass);
|
||||
|
||||
if (!formVal[subject]) formVal[subject] = [{}];
|
||||
|
||||
// Map actions to the keys defined in ApprovalPolicyActionSchema
|
||||
if (canCreate) formVal[subject]![0][ProjectPermissionApprovalActions.Create] = true;
|
||||
if (canDelete) formVal[subject]![0][ProjectPermissionApprovalActions.Delete] = true;
|
||||
if (canEdit) formVal[subject]![0][ProjectPermissionApprovalActions.Edit] = true;
|
||||
if (canRead) formVal[subject]![0][ProjectPermissionApprovalActions.Read] = true;
|
||||
if (canChangeBypass)
|
||||
formVal[subject]![0][ProjectPermissionApprovalActions.AllowChangeBypass] = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (subject === ProjectPermissionSub.SecretRollback) {
|
||||
const canRead = action.includes(ProjectPermissionActions.Read);
|
||||
const canCreate = action.includes(ProjectPermissionActions.Create);
|
||||
@@ -1181,10 +1208,11 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
||||
[ProjectPermissionSub.SecretApproval]: {
|
||||
title: "Secret Approval Policies",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
{ label: "Read", value: ProjectPermissionApprovalActions.Read },
|
||||
{ label: "Create", value: ProjectPermissionApprovalActions.Create },
|
||||
{ label: "Modify", value: ProjectPermissionApprovalActions.Edit },
|
||||
{ label: "Remove", value: ProjectPermissionApprovalActions.Delete },
|
||||
{ label: "Allow Change Bypass", value: ProjectPermissionApprovalActions.AllowChangeBypass }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.SecretRotation]: {
|
||||
@@ -1661,7 +1689,7 @@ export const RoleTemplates: Record<ProjectType, RoleTemplate[]> = {
|
||||
},
|
||||
{
|
||||
subject: ProjectPermissionSub.SecretApproval,
|
||||
actions: Object.values(ProjectPermissionActions)
|
||||
actions: Object.values(ProjectPermissionApprovalActions)
|
||||
},
|
||||
{
|
||||
subject: ProjectPermissionSub.ServiceTokens,
|
||||
|
@@ -32,6 +32,11 @@ import {
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { useSyncIntegration } from "@app/hooks/api/integrations/queries";
|
||||
@@ -110,7 +115,14 @@ export const IntegrationsTable = ({
|
||||
orderBy,
|
||||
setOrderDirection,
|
||||
setOrderBy
|
||||
} = usePagination<IntegrationsOrderBy>(IntegrationsOrderBy.App, { initPerPage: 20 });
|
||||
} = usePagination<IntegrationsOrderBy>(IntegrationsOrderBy.App, {
|
||||
initPerPage: getUserTablePreference("integrationsTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("integrationsTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (integrations?.some((integration) => integration.isSynced === false))
|
||||
@@ -437,7 +449,7 @@ export const IntegrationsTable = ({
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !filteredIntegrations?.length && (
|
||||
|
@@ -38,6 +38,11 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import {
|
||||
@@ -119,7 +124,14 @@ export const SecretSyncsTable = ({ secretSyncs }: Props) => {
|
||||
orderBy,
|
||||
setOrderDirection,
|
||||
setOrderBy
|
||||
} = usePagination<SecretSyncsOrderBy>(SecretSyncsOrderBy.Name, { initPerPage: 20 });
|
||||
} = usePagination<SecretSyncsOrderBy>(SecretSyncsOrderBy.Name, {
|
||||
initPerPage: getUserTablePreference("secretSyncTable", PreferenceKey.PerPage, 20)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("secretSyncTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const filteredSecretSyncs = useMemo(
|
||||
() =>
|
||||
@@ -465,7 +477,7 @@ export const SecretSyncsTable = ({ secretSyncs }: Props) => {
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
{!filteredSecretSyncs?.length && (
|
||||
|
@@ -64,6 +64,11 @@ import {
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import {
|
||||
getUserTablePreference,
|
||||
PreferenceKey,
|
||||
setUserTablePreference
|
||||
} from "@app/helpers/userTablePreferences";
|
||||
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import {
|
||||
useCreateFolder,
|
||||
@@ -180,7 +185,14 @@ export const OverviewPage = () => {
|
||||
page,
|
||||
setPerPage,
|
||||
orderBy
|
||||
} = usePagination<DashboardSecretsOrderBy>(DashboardSecretsOrderBy.Name);
|
||||
} = usePagination<DashboardSecretsOrderBy>(DashboardSecretsOrderBy.Name, {
|
||||
initPerPage: getUserTablePreference("secretOverviewTable", PreferenceKey.PerPage, 100)
|
||||
});
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPerPage(newPerPage);
|
||||
setUserTablePreference("secretOverviewTable", PreferenceKey.PerPage, newPerPage);
|
||||
};
|
||||
|
||||
const resetSelectedEntries = useCallback(() => {
|
||||
setSelectedEntries({
|
||||
@@ -1416,7 +1428,7 @@ export const OverviewPage = () => {
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
|
||||
onChangePerPage={handlePerPageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@@ -232,6 +232,7 @@ export const SecretEditRow = ({
|
||||
environment={environment}
|
||||
isImport={isImportedSecret}
|
||||
defaultValue={secretValueHidden ? "" : undefined}
|
||||
canEditButNotView={secretValueHidden && !isOverride}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
@@ -29,13 +29,13 @@ import {
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
TProjectPermission,
|
||||
useProjectPermission,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { ProjectPermissionApprovalActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useDeleteAccessApprovalPolicy,
|
||||
@@ -61,8 +61,10 @@ const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?:
|
||||
projectSlug: currentWorkspace?.slug as string,
|
||||
options: {
|
||||
enabled:
|
||||
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) &&
|
||||
!!currentWorkspace?.slug
|
||||
permission.can(
|
||||
ProjectPermissionApprovalActions.Read,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
) && !!currentWorkspace?.slug
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -71,8 +73,10 @@ const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?:
|
||||
workspaceId: currentWorkspace?.id as string,
|
||||
options: {
|
||||
enabled:
|
||||
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) &&
|
||||
!!currentWorkspace?.id
|
||||
permission.can(
|
||||
ProjectPermissionApprovalActions.Read,
|
||||
ProjectPermissionSub.SecretApproval
|
||||
) && !!currentWorkspace?.id
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -160,7 +164,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
|
||||
</div>
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
I={ProjectPermissionApprovalActions.Create}
|
||||
a={ProjectPermissionSub.SecretApproval}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user