Add lockout-preventative step in saml config setup, add update org slug section in org settings, revise navigate to org flow to account for org-level auth enforced orgs

This commit is contained in:
Tuan Dang
2024-02-06 15:51:24 -08:00
parent c1aa5c840c
commit fc7015de83
29 changed files with 409 additions and 125 deletions
@@ -1,24 +1,23 @@
import { Knex } from "knex"; import { Knex } from "knex";
import { TableName } from "../schemas"; import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> { export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable(TableName.Organization, (t) => { await knex.schema.alterTable(TableName.Organization, (t) => {
t.boolean("authEnabled").defaultTo(false); t.boolean("authEnforced").defaultTo(false);
}); });
await knex(TableName.Organization) await knex.schema.alterTable(TableName.SamlConfig, (t) => {
.whereIn( t.datetime("lastUsed");
"id", });
knex(TableName.SamlConfig)
.select("orgId")
.where("isActive", true)
)
.update({ authEnabled: true });
} }
export async function down(knex: Knex): Promise<void> { export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable(TableName.Organization, (t) => { await knex.schema.alterTable(TableName.Organization, (t) => {
t.dropColumn("authEnabled"); t.dropColumn("authEnforced");
}); });
}
await knex.schema.alterTable(TableName.SamlConfig, (t) => {
t.dropColumn("lastUsed");
});
}
+1 -1
View File
@@ -14,7 +14,7 @@ export const OrganizationsSchema = z.object({
slug: z.string(), slug: z.string(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
authEnabled: z.boolean().default(false).nullable().optional(), authEnforced: z.boolean().default(false).nullable().optional()
}); });
export type TOrganizations = z.infer<typeof OrganizationsSchema>; export type TOrganizations = z.infer<typeof OrganizationsSchema>;
+2 -1
View File
@@ -22,7 +22,8 @@ export const SamlConfigsSchema = z.object({
certTag: z.string().nullable().optional(), certTag: z.string().nullable().optional(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date(), updatedAt: z.date(),
orgId: z.string().uuid() orgId: z.string().uuid(),
lastUsed: z.date().nullable().optional()
}); });
export type TSamlConfigs = z.infer<typeof SamlConfigsSchema>; export type TSamlConfigs = z.infer<typeof SamlConfigsSchema>;
+6 -5
View File
@@ -45,19 +45,19 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
getSamlOptions: async (req, done) => { getSamlOptions: async (req, done) => {
try { try {
const { samlConfigId, orgSlug } = req.params; const { samlConfigId, orgSlug } = req.params;
let ssoLookupDetails: TGetSamlCfgDTO; let ssoLookupDetails: TGetSamlCfgDTO;
if (orgSlug) { if (orgSlug) {
ssoLookupDetails = { ssoLookupDetails = {
type: "orgSlug", type: "orgSlug",
orgSlug orgSlug
} };
} else if (samlConfigId) { } else if (samlConfigId) {
ssoLookupDetails = { ssoLookupDetails = {
type: "ssoId", type: "ssoId",
id: samlConfigId id: samlConfigId
} };
} else { } else {
throw new BadRequestError({ message: "Missing sso identitier or org slug" }); throw new BadRequestError({ message: "Missing sso identitier or org slug" });
} }
@@ -215,7 +215,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
isActive: z.boolean(), isActive: z.boolean(),
entryPoint: z.string(), entryPoint: z.string(),
issuer: z.string(), issuer: z.string(),
cert: z.string() cert: z.string(),
lastUsed: z.date().nullable().optional()
}) })
.optional() .optional()
} }
@@ -13,7 +13,7 @@ export const permissionDALFactory = (db: TDbClient) => {
.join(TableName.Organization, `${TableName.OrgMembership}.orgId`, `${TableName.Organization}.id`) .join(TableName.Organization, `${TableName.OrgMembership}.orgId`, `${TableName.Organization}.id`)
.where("userId", userId) .where("userId", userId)
.where(`${TableName.OrgMembership}.orgId`, orgId) .where(`${TableName.OrgMembership}.orgId`, orgId)
.select(db.ref("authEnabled").withSchema(TableName.Organization).as("orgAuthEnabled")) .select(db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"))
.select("permissions") .select("permissions")
.select(selectAllTableCols(TableName.OrgMembership)) .select(selectAllTableCols(TableName.OrgMembership))
.first(); .first();
@@ -32,7 +32,7 @@ export const permissionDALFactory = (db: TDbClient) => {
.where("identityId", identityId) .where("identityId", identityId)
.where(`${TableName.IdentityOrgMembership}.orgId`, orgId) .where(`${TableName.IdentityOrgMembership}.orgId`, orgId)
.select(selectAllTableCols(TableName.IdentityOrgMembership)) .select(selectAllTableCols(TableName.IdentityOrgMembership))
.select(db.ref("authEnabled").withSchema(TableName.Organization).as("orgAuthEnabled")) .select(db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"))
.select("permissions") .select("permissions")
.first(); .first();
return membership; return membership;
@@ -51,7 +51,7 @@ export const permissionDALFactory = (db: TDbClient) => {
.where(`${TableName.ProjectMembership}.projectId`, projectId) .where(`${TableName.ProjectMembership}.projectId`, projectId)
.select(selectAllTableCols(TableName.ProjectMembership)) .select(selectAllTableCols(TableName.ProjectMembership))
.select( .select(
db.ref("authEnabled").withSchema(TableName.Organization).as("orgAuthEnabled"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
db.ref("orgId").withSchema(TableName.Project) db.ref("orgId").withSchema(TableName.Project)
) )
.select("permissions") .select("permissions")
@@ -100,21 +100,18 @@ export const permissionServiceFactory = ({
if (membership.role === OrgMembershipRole.Custom && !membership.permissions) { if (membership.role === OrgMembershipRole.Custom && !membership.permissions) {
throw new BadRequestError({ name: "Custom permission not found" }); throw new BadRequestError({ name: "Custom permission not found" });
} }
if (membership.orgAuthEnabled && membership.orgId !== orgScope) { if (membership.orgAuthEnforced && membership.orgId !== orgScope) {
throw new BadRequestError({ name: "Cannot access org-scoped resource" }); throw new BadRequestError({ name: "Cannot access org-scoped resource" });
} }
return { permission: buildOrgPermission(membership.role, membership.permissions), membership }; return { permission: buildOrgPermission(membership.role, membership.permissions), membership };
}; };
const getIdentityOrgPermission = async (identityId: string, orgId: string, orgScope?: string) => { const getIdentityOrgPermission = async (identityId: string, orgId: string) => {
const membership = await permissionDAL.getOrgIdentityPermission(identityId, orgId); const membership = await permissionDAL.getOrgIdentityPermission(identityId, orgId);
if (!membership) throw new UnauthorizedError({ name: "Identity not in org" }); if (!membership) throw new UnauthorizedError({ name: "Identity not in org" });
if (membership.role === OrgMembershipRole.Custom && !membership.permissions) { if (membership.role === OrgMembershipRole.Custom && !membership.permissions) {
throw new BadRequestError({ name: "Custom permission not found" }); throw new BadRequestError({ name: "Custom permission not found" });
} }
if (membership.orgAuthEnabled && membership.orgId !== orgScope) {
throw new BadRequestError({ name: "Cannot access org-scoped resource" });
}
return { permission: buildOrgPermission(membership.role, membership.permissions), membership }; return { permission: buildOrgPermission(membership.role, membership.permissions), membership };
}; };
@@ -123,7 +120,7 @@ export const permissionServiceFactory = ({
case ActorType.USER: case ActorType.USER:
return getUserOrgPermission(id, orgId, orgScope); return getUserOrgPermission(id, orgId, orgScope);
case ActorType.IDENTITY: case ActorType.IDENTITY:
return getIdentityOrgPermission(id, orgId, orgScope); return getIdentityOrgPermission(id, orgId);
default: default:
throw new UnauthorizedError({ throw new UnauthorizedError({
message: "Permission not defined", message: "Permission not defined",
@@ -154,9 +151,11 @@ export const permissionServiceFactory = ({
if (membership.role === ProjectMembershipRole.Custom && !membership.permissions) { if (membership.role === ProjectMembershipRole.Custom && !membership.permissions) {
throw new BadRequestError({ name: "Custom permission not found" }); throw new BadRequestError({ name: "Custom permission not found" });
} }
if (membership.orgAuthEnabled && membership.orgId !== orgScope) {
if (membership.orgAuthEnforced && membership.orgId !== orgScope) {
throw new BadRequestError({ name: "Cannot access org-scoped resource" }); throw new BadRequestError({ name: "Cannot access org-scoped resource" });
} }
return { return {
permission: buildProjectPermission(membership.role, membership.permissions), permission: buildProjectPermission(membership.role, membership.permissions),
membership membership
@@ -169,6 +168,7 @@ export const permissionServiceFactory = ({
if (membership.role === ProjectMembershipRole.Custom && !membership.permissions) { if (membership.role === ProjectMembershipRole.Custom && !membership.permissions) {
throw new BadRequestError({ name: "Custom permission not found" }); throw new BadRequestError({ name: "Custom permission not found" });
} }
return { return {
permission: buildProjectPermission(membership.role, membership.permissions), permission: buildProjectPermission(membership.role, membership.permissions),
membership membership
@@ -193,11 +193,12 @@ export const permissionServiceFactory = ({
: { : {
permission: MongoAbility<ProjectPermissionSet, MongoQuery>; permission: MongoAbility<ProjectPermissionSet, MongoQuery>;
membership: (T extends ActorType.USER ? TProjectMemberships : TIdentityProjectMemberships) & { membership: (T extends ActorType.USER ? TProjectMemberships : TIdentityProjectMemberships) & {
orgAuthEnforced: boolean;
orgId: string;
permissions?: unknown; permissions?: unknown;
}; };
}; };
// TODO: add support for org scope here
const getProjectPermission = async <T extends ActorType>( const getProjectPermission = async <T extends ActorType>(
type: T, type: T,
id: string, id: string,
@@ -207,9 +208,9 @@ export const permissionServiceFactory = ({
switch (type) { switch (type) {
case ActorType.USER: case ActorType.USER:
return getUserProjectPermission(id, projectId, orgScope) as Promise<TProjectPermissionRT<T>>; return getUserProjectPermission(id, projectId, orgScope) as Promise<TProjectPermissionRT<T>>;
case ActorType.SERVICE: // how to handle org-scope case here? case ActorType.SERVICE:
return getServiceTokenProjectPermission(id, projectId) as Promise<TProjectPermissionRT<T>>; return getServiceTokenProjectPermission(id, projectId) as Promise<TProjectPermissionRT<T>>;
case ActorType.IDENTITY: // how to handle org-scope case here? case ActorType.IDENTITY:
return getIdentityProjectPermission(id, projectId) as Promise<TProjectPermissionRT<T>>; return getIdentityProjectPermission(id, projectId) as Promise<TProjectPermissionRT<T>>;
default: default:
throw new UnauthorizedError({ throw new UnauthorizedError({
@@ -1,10 +1,31 @@
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas"; import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex"; import { ormify } from "@app/lib/knex";
export type TSamlConfigDALFactory = ReturnType<typeof samlConfigDALFactory>; export type TSamlConfigDALFactory = ReturnType<typeof samlConfigDALFactory>;
export const samlConfigDALFactory = (db: TDbClient) => { export const samlConfigDALFactory = (db: TDbClient) => {
const samlCfgOrm = ormify(db, TableName.SamlConfig); const samlCfgOrm = ormify(db, TableName.SamlConfig);
return samlCfgOrm;
const findEnforceableSamlCfg = async (orgId: string) => {
try {
const samlCfg = await db(TableName.SamlConfig)
.where({
orgId,
isActive: true
})
.whereNotNull("lastUsed")
.first();
return samlCfg;
} catch (error) {
throw new DatabaseError({ error, name: "Find org by id" });
}
};
return {
...samlCfgOrm,
findEnforceableSamlCfg
};
}; };
@@ -18,7 +18,7 @@ import {
infisicalSymmetricEncypt infisicalSymmetricEncypt
} from "@app/lib/crypto/encryption"; } from "@app/lib/crypto/encryption";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { AuthTokenType } from "@app/services/auth/auth-type"; import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type";
import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal";
import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal";
import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserDALFactory } from "@app/services/user/user-dal";
@@ -27,18 +27,15 @@ import { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service"; import { TPermissionServiceFactory } from "../permission/permission-service";
import { TSamlConfigDALFactory } from "./saml-config-dal"; import { TSamlConfigDALFactory } from "./saml-config-dal";
import { import { TCreateSamlCfgDTO, TGetSamlCfgDTO, TSamlLoginDTO, TUpdateSamlCfgDTO } from "./saml-config-types";
SamlProviders,
TCreateSamlCfgDTO,
TGetSamlCfgDTO,
TSamlLoginDTO,
TUpdateSamlCfgDTO
} from "./saml-config-types";
type TSamlConfigServiceFactoryDep = { type TSamlConfigServiceFactoryDep = {
samlConfigDAL: TSamlConfigDALFactory; samlConfigDAL: TSamlConfigDALFactory;
userDAL: Pick<TUserDALFactory, "create" | "findUserByEmail" | "transaction" | "updateById">; userDAL: Pick<TUserDALFactory, "create" | "findUserByEmail" | "transaction" | "updateById">;
orgDAL: Pick<TOrgDALFactory, "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById">; orgDAL: Pick<
TOrgDALFactory,
"createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById"
>;
orgBotDAL: Pick<TOrgBotDALFactory, "findOne" | "create" | "transaction">; orgBotDAL: Pick<TOrgBotDALFactory, "findOne" | "create" | "transaction">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">; permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
@@ -141,7 +138,6 @@ export const samlConfigServiceFactory = ({
certIV, certIV,
certTag certTag
}); });
await orgDAL.updateById(orgId, { authEnabled: isActive });
return samlConfig; return samlConfig;
}; };
@@ -166,7 +162,7 @@ export const samlConfigServiceFactory = ({
"Failed to update SAML SSO configuration due to plan restriction. Upgrade plan to update SSO configuration." "Failed to update SAML SSO configuration due to plan restriction. Upgrade plan to update SSO configuration."
}); });
const updateQuery: TSamlConfigsUpdate = { authProvider, isActive }; const updateQuery: TSamlConfigsUpdate = { authProvider, isActive, lastUsed: null };
const orgBot = await orgBotDAL.findOne({ orgId }); const orgBot = await orgBotDAL.findOne({ orgId });
if (!orgBot) throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" }); if (!orgBot) throw new BadRequestError({ message: "Org bot not found", name: "OrgBotNotFound" });
const key = infisicalSymmetricDecrypt({ const key = infisicalSymmetricDecrypt({
@@ -199,8 +195,8 @@ export const samlConfigServiceFactory = ({
updateQuery.certTag = certTag; updateQuery.certTag = certTag;
} }
const [ssoConfig] = await samlConfigDAL.update({ orgId }, updateQuery); const [ssoConfig] = await samlConfigDAL.update({ orgId }, updateQuery);
await orgDAL.updateById(orgId, { authEnabled: isActive }); await orgDAL.updateById(orgId, { authEnforced: false });
return ssoConfig; return ssoConfig;
}; };
@@ -237,7 +233,12 @@ export const samlConfigServiceFactory = ({
// when dto is type id means it's internally used // when dto is type id means it's internally used
if (dto.type === "org") { if (dto.type === "org") {
const { permission } = await permissionService.getOrgPermission(dto.actor, dto.actorId, ssoConfig.orgId, dto.actorOrgScope); const { permission } = await permissionService.getOrgPermission(
dto.actor,
dto.actorId,
ssoConfig.orgId,
dto.actorOrgScope
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Sso);
} }
const { const {
@@ -294,7 +295,8 @@ export const samlConfigServiceFactory = ({
isActive: ssoConfig.isActive, isActive: ssoConfig.isActive,
entryPoint, entryPoint,
issuer, issuer,
cert cert,
lastUsed: ssoConfig.lastUsed
}; };
}; };
@@ -316,13 +318,7 @@ export const samlConfigServiceFactory = ({
if (!organization) throw new BadRequestError({ message: "Org not found" }); if (!organization) throw new BadRequestError({ message: "Org not found" });
if (user) { if (user) {
const hasSamlEnabled = (user.authMethods || []).some((method) =>
Object.values(SamlProviders).includes(method as SamlProviders)
);
await userDAL.transaction(async (tx) => { await userDAL.transaction(async (tx) => {
if (!hasSamlEnabled) {
await userDAL.updateById(user.id, { authMethods: [authProvider] }, tx);
}
const [orgMembership] = await orgDAL.findMembership({ userId: user.id, orgId }, { tx }); const [orgMembership] = await orgDAL.findMembership({ userId: user.id, orgId }, { tx });
if (!orgMembership) { if (!orgMembership) {
await orgDAL.createMembership( await orgDAL.createMembership(
@@ -352,7 +348,7 @@ export const samlConfigServiceFactory = ({
email, email,
firstName, firstName,
lastName, lastName,
authMethods: [authProvider] authMethods: [AuthMethod.EMAIL]
}, },
tx tx
); );
@@ -388,6 +384,9 @@ export const samlConfigServiceFactory = ({
expiresIn: appCfg.JWT_PROVIDER_AUTH_LIFETIME expiresIn: appCfg.JWT_PROVIDER_AUTH_LIFETIME
} }
); );
await samlConfigDAL.update({ orgId }, { lastUsed: new Date() });
return { isUserCompleted, providerAuthToken }; return { isUserCompleted, providerAuthToken };
}; };
@@ -11,9 +11,9 @@ export const injectPermission = fp(async (server) => {
if (req.auth.actor === ActorType.USER) { if (req.auth.actor === ActorType.USER) {
req.permission = { type: ActorType.USER, id: req.auth.userId, orgId: req.auth?.orgId }; req.permission = { type: ActorType.USER, id: req.auth.userId, orgId: req.auth?.orgId };
} else if (req.auth.actor === ActorType.IDENTITY) { } else if (req.auth.actor === ActorType.IDENTITY) {
req.permission = { type: ActorType.IDENTITY, id: req.auth.identityId, orgId: undefined }; req.permission = { type: ActorType.IDENTITY, id: req.auth.identityId };
} else if (req.auth.actor === ActorType.SERVICE) { } else if (req.auth.actor === ActorType.SERVICE) {
req.permission = { type: ActorType.SERVICE, id: req.auth.serviceTokenId, orgId: undefined }; req.permission = { type: ActorType.SERVICE, id: req.auth.serviceTokenId };
} }
}); });
}); });
@@ -83,10 +83,14 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
method: "PATCH", method: "PATCH",
url: "/:organizationId/name", url: "/:organizationId",
schema: { schema: {
params: z.object({ organizationId: z.string().trim() }), params: z.object({ organizationId: z.string().trim() }),
body: z.object({ name: z.string().trim() }), body: z.object({
name: z.string().trim().optional(),
slug: z.string().trim().optional(),
authEnforced: z.boolean().optional()
}),
response: { response: {
200: z.object({ 200: z.object({
message: z.string(), message: z.string(),
@@ -96,12 +100,14 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => { handler: async (req) => {
const organization = await server.services.org.updateOrgName( const organization = await server.services.org.updateOrg({
req.permission.id, actor: req.permission.type,
req.params.organizationId, actorId: req.permission.id,
req.body.name, actorOrgScope: req.permission.orgId,
req.permission.orgId orgId: req.params.organizationId,
); data: req.body
});
return { return {
message: "Successfully changed organization name", message: "Successfully changed organization name",
organization organization
+21 -19
View File
@@ -56,13 +56,7 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
* Private * Private
* Send mfa code via email * Send mfa code via email
* */ * */
const sendUserMfaCode = async ({ const sendUserMfaCode = async ({ userId, email }: { userId: string; email: string }) => {
userId,
email
}: {
userId: string;
email: string;
}) => {
const code = await tokenService.createTokenForUser({ const code = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_MFA, type: TokenType.TOKEN_EMAIL_MFA,
userId userId
@@ -171,6 +165,10 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
if (!userEnc.authMethods?.includes(AuthMethod.EMAIL)) { if (!userEnc.authMethods?.includes(AuthMethod.EMAIL)) {
const { orgId } = validateProviderAuthToken(providerAuthToken as string, email); const { orgId } = validateProviderAuthToken(providerAuthToken as string, email);
organizationId = orgId; organizationId = orgId;
} else if (providerAuthToken) {
// SAML SSO
const { orgId } = validateProviderAuthToken(providerAuthToken, email);
organizationId = orgId;
} }
if (!userEnc.serverPrivateKey || !userEnc.clientPublicKey) throw new Error("Failed to authenticate. Try again?"); if (!userEnc.serverPrivateKey || !userEnc.clientPublicKey) throw new Error("Failed to authenticate. Try again?");
@@ -189,22 +187,26 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
}); });
// send multi factor auth token if they it enabled // send multi factor auth token if they it enabled
if (userEnc.isMfaEnabled) { if (userEnc.isMfaEnabled) {
const mfaToken = jwt.sign({ const mfaToken = jwt.sign(
authTokenType: AuthTokenType.MFA_TOKEN, {
userId: userEnc.userId, authTokenType: AuthTokenType.MFA_TOKEN,
organizationId userId: userEnc.userId,
}, cfg.AUTH_SECRET, { organizationId
expiresIn: cfg.JWT_MFA_LIFETIME },
}); cfg.AUTH_SECRET,
{
expiresIn: cfg.JWT_MFA_LIFETIME
}
);
await sendUserMfaCode({ await sendUserMfaCode({
userId: userEnc.userId, userId: userEnc.userId,
email: userEnc.email email: userEnc.email
}); });
return { isMfaEnabled: true, token: mfaToken } as const; return { isMfaEnabled: true, token: mfaToken } as const;
} }
const token = await generateUserTokens({ const token = await generateUserTokens({
user: { user: {
...userEnc, ...userEnc,
@@ -214,7 +216,7 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
userAgent, userAgent,
organizationId organizationId
}); });
return { token, isMfaEnabled: false, user: userEnc } as const; return { token, isMfaEnabled: false, user: userEnc } as const;
}; };
@@ -227,7 +229,7 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
if (!user) return; if (!user) return;
await sendUserMfaCode({ await sendUserMfaCode({
userId: user.id, userId: user.id,
email: user.email, email: user.email
}); });
}; };
@@ -127,6 +127,7 @@ export const identityUaServiceFactory = ({
expiresIn: identityAccessToken.accessTokenMaxTTL === 0 ? undefined : identityAccessToken.accessTokenMaxTTL expiresIn: identityAccessToken.accessTokenMaxTTL === 0 ? undefined : identityAccessToken.accessTokenMaxTTL
} }
); );
return { accessToken, identityUa, validClientSecretInfo, identityAccessToken }; return { accessToken, identityUa, validClientSecretInfo, identityAccessToken };
}; };
@@ -152,7 +153,12 @@ export const identityUaServiceFactory = ({
throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" });
} }
const { permission } = await permissionService.getOrgPermission(actor, actorId, identityMembershipOrg.orgId, actorOrgScope); const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId,
actorOrgScope
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity);
const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
@@ -241,7 +247,12 @@ export const identityUaServiceFactory = ({
throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" });
} }
const { permission } = await permissionService.getOrgPermission(actor, actorId, identityMembershipOrg.orgId, actorOrgScope); const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId,
actorOrgScope
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity);
const plan = await licenseService.getPlan(identityMembershipOrg.orgId); const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
@@ -302,7 +313,12 @@ export const identityUaServiceFactory = ({
const uaIdentityAuth = await identityUaDAL.findOne({ identityId }); const uaIdentityAuth = await identityUaDAL.findOne({ identityId });
const { permission } = await permissionService.getOrgPermission(actor, actorId, identityMembershipOrg.orgId, actorOrgScope); const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId,
actorOrgScope
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity);
return { ...uaIdentityAuth, orgId: identityMembershipOrg.orgId }; return { ...uaIdentityAuth, orgId: identityMembershipOrg.orgId };
}; };
@@ -322,7 +338,12 @@ export const identityUaServiceFactory = ({
throw new BadRequestError({ throw new BadRequestError({
message: "The identity does not have universal auth" message: "The identity does not have universal auth"
}); });
const { permission } = await permissionService.getOrgPermission(actor, actorId, identityMembershipOrg.orgId, actorOrgScope); const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId,
actorOrgScope
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity);
const { permission: rolePermission } = await permissionService.getOrgPermission( const { permission: rolePermission } = await permissionService.getOrgPermission(
@@ -369,7 +390,12 @@ export const identityUaServiceFactory = ({
throw new BadRequestError({ throw new BadRequestError({
message: "The identity does not have universal auth" message: "The identity does not have universal auth"
}); });
const { permission } = await permissionService.getOrgPermission(actor, actorId, identityMembershipOrg.orgId, actorOrgScope); const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId,
actorOrgScope
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity);
const { permission: rolePermission } = await permissionService.getOrgPermission( const { permission: rolePermission } = await permissionService.getOrgPermission(
@@ -395,14 +421,25 @@ export const identityUaServiceFactory = ({
return { clientSecrets, orgId: identityMembershipOrg.orgId }; return { clientSecrets, orgId: identityMembershipOrg.orgId };
}; };
const revokeUaClientSecret = async ({ identityId, actorId, actor, actorOrgScope, clientSecretId }: TRevokeUaClientSecretDTO) => { const revokeUaClientSecret = async ({
identityId,
actorId,
actor,
actorOrgScope,
clientSecretId
}: TRevokeUaClientSecretDTO) => {
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral) if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral)
throw new BadRequestError({ throw new BadRequestError({
message: "The identity does not have universal auth" message: "The identity does not have universal auth"
}); });
const { permission } = await permissionService.getOrgPermission(actor, actorId, identityMembershipOrg.orgId, actorOrgScope); const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId,
actorOrgScope
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity);
const { permission: rolePermission } = await permissionService.getOrgPermission( const { permission: rolePermission } = await permissionService.getOrgPermission(
+17 -6
View File
@@ -29,6 +29,7 @@ import {
TDeleteOrgMembershipDTO, TDeleteOrgMembershipDTO,
TFindAllWorkspacesDTO, TFindAllWorkspacesDTO,
TInviteUserToOrgDTO, TInviteUserToOrgDTO,
TUpdateOrgDTO,
TUpdateOrgMembershipDTO, TUpdateOrgMembershipDTO,
TVerifyUserToOrgDTO TVerifyUserToOrgDTO
} from "./org-types"; } from "./org-types";
@@ -40,7 +41,7 @@ type TOrgServiceFactoryDep = {
userDAL: TUserDALFactory; userDAL: TUserDALFactory;
projectDAL: TProjectDALFactory; projectDAL: TProjectDALFactory;
incidentContactDAL: TIncidentContactsDALFactory; incidentContactDAL: TIncidentContactsDALFactory;
samlConfigDAL: Pick<TSamlConfigDALFactory, "findOne">; samlConfigDAL: Pick<TSamlConfigDALFactory, "findOne" | "findEnforceableSamlCfg">;
smtpService: TSmtpService; smtpService: TSmtpService;
tokenService: TAuthTokenServiceFactory; tokenService: TAuthTokenServiceFactory;
permissionService: TPermissionServiceFactory; permissionService: TPermissionServiceFactory;
@@ -118,12 +119,22 @@ export const orgServiceFactory = ({
}; };
/* /*
* Update organization settings * Update organization details
* */ * */
const updateOrgName = async (userId: string, orgId: string, name: string, actorOrgScope?: string) => { const updateOrg = async ({ actor, actorId, actorOrgScope, orgId, data }: TUpdateOrgDTO) => {
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgScope); const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgScope);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings);
const org = await orgDAL.updateById(orgId, { name });
if (data.authEnforced) {
const samlCfg = await samlConfigDAL.findEnforceableSamlCfg(orgId);
if (!samlCfg)
throw new BadRequestError({
name: "No enforceable SAML config found",
message: "No enforceable SAML config found"
});
}
const org = await orgDAL.updateById(orgId, data);
if (!org) throw new BadRequestError({ name: "Org not found", message: "Organization not found" }); if (!org) throw new BadRequestError({ name: "Org not found", message: "Organization not found" });
return org; return org;
}; };
@@ -443,7 +454,7 @@ export const orgServiceFactory = ({
findAllOrganizationOfUser, findAllOrganizationOfUser,
inviteUserToOrganization, inviteUserToOrganization,
verifyUserToOrg, verifyUserToOrg,
updateOrgName, updateOrg,
createOrganization, createOrganization,
deleteOrganizationById, deleteOrganizationById,
deleteOrgMembership, deleteOrgMembership,
+6
View File
@@ -1,3 +1,5 @@
import { TOrgPermission } from "@app/lib/types";
import { ActorType } from "../auth/auth-type"; import { ActorType } from "../auth/auth-type";
export type TUpdateOrgMembershipDTO = { export type TUpdateOrgMembershipDTO = {
@@ -34,3 +36,7 @@ export type TFindAllWorkspacesDTO = {
actorOrgScope?: string; actorOrgScope?: string;
orgId: string; orgId: string;
}; };
export type TUpdateOrgDTO = {
data: Partial<{ name: string; slug: string; authEnforced: boolean }>;
} & TOrgPermission;
@@ -25,7 +25,7 @@ export const OrgProvider = ({ children }: Props): JSX.Element => {
const value = useMemo<TOrgContext>( const value = useMemo<TOrgContext>(
() => ({ () => ({
orgs: userOrgs, orgs: userOrgs,
currentOrg: (userOrgs || []).find(({ id }) => id === currentWsOrgID) || (userOrgs || [])[0], currentOrg: (userOrgs || []).find(({ id }) => id === currentWsOrgID),
isLoading isLoading
}), }),
[currentWsOrgID, userOrgs, isLoading] [currentWsOrgID, userOrgs, isLoading]
+1 -1
View File
@@ -17,6 +17,6 @@ export {
useGetOrgPmtMethods, useGetOrgPmtMethods,
useGetOrgTaxIds, useGetOrgTaxIds,
useGetOrgTrialUrl, useGetOrgTrialUrl,
useRenameOrg, useUpdateOrg,
useUpdateOrgBillingDetails useUpdateOrgBillingDetails
} from "./queries"; } from "./queries";
@@ -12,8 +12,8 @@ import {
PlanBillingInfo, PlanBillingInfo,
PmtMethod, PmtMethod,
ProductsTable, ProductsTable,
RenameOrgDTO, TaxID,
TaxID UpdateOrgDTO
} from "./types"; } from "./types";
export const organizationKeys = { export const organizationKeys = {
@@ -65,12 +65,20 @@ export const useCreateOrg = () => {
}); });
}; };
export const useRenameOrg = () => { export const useUpdateOrg = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<{}, {}, UpdateOrgDTO>({
return useMutation<{}, {}, RenameOrgDTO>({ mutationFn: ({
mutationFn: ({ newOrgName, orgId }) => { name,
return apiRequest.patch(`/api/v1/organization/${orgId}/name`, { name: newOrgName }); authEnforced,
slug,
orgId
}) => {
return apiRequest.patch(`/api/v1/organization/${orgId}`, {
name,
authEnforced,
slug
});
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries(organizationKeys.getUserOrganizations); queryClient.invalidateQueries(organizationKeys.getUserOrganizations);
+6 -2
View File
@@ -3,11 +3,15 @@ export type Organization = {
name: string; name: string;
createAt: string; createAt: string;
updatedAt: string; updatedAt: string;
authEnforced: boolean;
slug: string;
}; };
export type RenameOrgDTO = { export type UpdateOrgDTO = {
orgId: string; orgId: string;
newOrgName: string; name?: string;
authEnforced?: boolean;
slug?: string;
}; };
export type BillingDetails = { export type BillingDetails = {
+16 -3
View File
@@ -310,10 +310,22 @@ export const AppLayout = ({ children }: LayoutProps) => {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1"> <DropdownMenuContent align="start" className="p-1">
<div className="px-2 py-1 text-xs text-mineshaft-400">{user?.email}</div> <div className="px-2 py-1 text-xs text-mineshaft-400">{user?.email}</div>
{orgs?.map((org) => ( {orgs?.map((org) => {
return (
<DropdownMenuItem key={org.id}> <DropdownMenuItem key={org.id}>
<Button <Button
onClick={() => changeOrg(org?.id)} onClick={() => {
if (currentOrg?.id === org.id) return;
if (org.authEnforced) {
// org has an org-level auth method enabled (e.g. SAML)
// -> logout + redirect to SAML SSO
logOutUser();
return;
}
changeOrg(org?.id)
}}
variant="plain" variant="plain"
colorSchema="secondary" colorSchema="secondary"
size="xs" size="xs"
@@ -329,7 +341,8 @@ export const AppLayout = ({ children }: LayoutProps) => {
</div> </div>
</Button> </Button>
</DropdownMenuItem> </DropdownMenuItem>
))} )
})}
{/* <DropdownMenuItem key="add-org"> {/* <DropdownMenuItem key="add-org">
<Button <Button
onClick={() => handlePopUpOpen("createOrg")} onClick={() => handlePopUpOpen("createOrg")}
-1
View File
@@ -10,7 +10,6 @@ import {
MFAStep, MFAStep,
SAMLSSOStep SAMLSSOStep
} from "./components"; } from "./components";
// import { navigateUserToOrg } from "../../Login.utils";
import { navigateUserToOrg } from "./Login.utils"; import { navigateUserToOrg } from "./Login.utils";
export const Login = () => { export const Login = () => {
+6 -4
View File
@@ -4,6 +4,8 @@ import { fetchOrganizations } from "@app/hooks/api/organization/queries";
export const navigateUserToOrg = async (router: NextRouter, organizationId?: string) => { export const navigateUserToOrg = async (router: NextRouter, organizationId?: string) => {
const userOrgs = await fetchOrganizations(); const userOrgs = await fetchOrganizations();
const nonAuthEnforcedOrgs = userOrgs.filter((org) => !org.authEnforced);
if (organizationId) { if (organizationId) {
localStorage.setItem("orgData.id", organizationId); localStorage.setItem("orgData.id", organizationId);
@@ -11,13 +13,13 @@ export const navigateUserToOrg = async (router: NextRouter, organizationId?: str
return; return;
} }
if (userOrgs.length > 0) { if (nonAuthEnforcedOrgs.length > 0) {
// user is part of at least 1 org // user is part of at least 1 non-auth enforced org
const userOrg = userOrgs[0] && userOrgs[0].id; const userOrg = nonAuthEnforcedOrgs[0] && nonAuthEnforcedOrgs[0].id;
localStorage.setItem("orgData.id", userOrg); localStorage.setItem("orgData.id", userOrg);
router.push(`/org/${userOrg}/overview`); router.push(`/org/${userOrg}/overview`);
} else { } else {
// user is not part of any org // user is not part of any non-auth enforced orgs
localStorage.removeItem("orgData.id"); localStorage.removeItem("orgData.id");
router.push("/org/none"); router.push("/org/none");
} }
+7 -1
View File
@@ -1,9 +1,15 @@
import { useEffect } from "react";
import { usePopUp } from "@app/hooks/usePopUp"; import { usePopUp } from "@app/hooks/usePopUp";
import { CreateOrgModal } from "../components"; import { CreateOrgModal } from "../components";
export const NonePage = () => { export const NonePage = () => {
const { popUp, handlePopUpToggle } = usePopUp(["createOrg"] as const); const { popUp, handlePopUpToggle } = usePopUp(["createOrg"] as const);
useEffect(() => {
handlePopUpToggle("createOrg", true);
}, []);
return ( return (
<div className="flex h-full w-full justify-center bg-bunker-800 text-white"> <div className="flex h-full w-full justify-center bg-bunker-800 text-white">
@@ -13,4 +19,4 @@ export const NonePage = () => {
/> />
</div> </div>
); );
}; };
@@ -1,12 +1,14 @@
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
import { withPermission } from "@app/hoc"; import { withPermission } from "@app/hoc";
import { OrgGeneralAuthSection } from "./OrgGeneralAuthSection";
import { OrgSSOSection } from "./OrgSSOSection"; import { OrgSSOSection } from "./OrgSSOSection";
export const OrgAuthTab = withPermission( export const OrgAuthTab = withPermission(
() => { () => {
return ( return (
<div> <div>
<OrgGeneralAuthSection />
<OrgSSOSection /> <OrgSSOSection />
</div> </div>
); );
@@ -0,0 +1,74 @@
import { useRouter } from "next/router";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { OrgPermissionCan } from "@app/components/permissions";
import { Switch } from "@app/components/v2";
import { OrgPermissionActions,
OrgPermissionSubjects,
useOrganization } from "@app/context";
import { useLogoutUser,useUpdateOrg } from "@app/hooks/api";
export const OrgGeneralAuthSection = () => {
const router = useRouter();
const { createNotification } = useNotificationContext();
const { currentOrg } = useOrganization();
const { mutateAsync } = useUpdateOrg();
const logout = useLogoutUser();
const logOutUser = async () => {
try {
console.log("Logging out...");
await logout.mutateAsync();
router.push("/login");
} catch (error) {
console.error(error);
}
};
const handleEnforceOrgAuthToggle = async (value: boolean) => {
try {
if (!currentOrg?.id) return;
await mutateAsync({
orgId: currentOrg?.id,
authEnforced: value
});
createNotification({
text: `Successfully ${value ? "enforced" : "un-enforced"} org-level auth`,
type: "success"
});
if (value) {
logOutUser();
}
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${value ? "enforce" : "un-enforce"} org-level auth`,
type: "error"
});
}
}
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<h2 className="flex-1 text-xl font-semibold text-white mb-8">Settings</h2>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Sso}>
{(isAllowed) => (
<Switch
id="enforce-org-auth"
onCheckedChange={(value) => handleEnforceOrgAuthToggle(value)}
isChecked={currentOrg?.authEnforced ?? false}
isDisabled={!isAllowed}
>
Enforce SAML SSO
</Switch>
)}
</OrgPermissionCan>
</div>
);
}
@@ -1,5 +1,6 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionCan } from "@app/components/permissions";
@@ -136,6 +137,10 @@ export const OrgSSOSection = (): JSX.Element => {
<h3 className="text-sm text-mineshaft-400">Issuer</h3> <h3 className="text-sm text-mineshaft-400">Issuer</h3>
<p className="text-md text-gray-400">{data && data.issuer !== "" ? data.issuer : "-"}</p> <p className="text-md text-gray-400">{data && data.issuer !== "" ? data.issuer : "-"}</p>
</div> </div>
<div className="mb-4">
<h3 className="text-sm text-mineshaft-400">Last Logged In</h3>
<p className="text-md text-gray-400">{data?.lastUsed ? format(new Date(data?.lastUsed), "yyyy-MM-dd HH:mm:ss") : "-"}</p>
</div>
<SSOModal <SSOModal
popUp={popUp} popUp={popUp}
handlePopUpClose={handlePopUpClose} handlePopUpClose={handlePopUpClose}
@@ -3,6 +3,7 @@ import { useOrgPermission } from "@app/context";
import { OrgDeleteSection } from "../OrgDeleteSection"; import { OrgDeleteSection } from "../OrgDeleteSection";
import { OrgIncidentContactsSection } from "../OrgIncidentContactsSection"; import { OrgIncidentContactsSection } from "../OrgIncidentContactsSection";
import { OrgNameChangeSection } from "../OrgNameChangeSection"; import { OrgNameChangeSection } from "../OrgNameChangeSection";
import { OrgSlugChangeSection } from "../OrgSlugChangeSection";
export const OrgGeneralTab = () => { export const OrgGeneralTab = () => {
const { membership } = useOrgPermission(); const { membership } = useOrgPermission();
@@ -10,8 +11,9 @@ export const OrgGeneralTab = () => {
return ( return (
<div> <div>
<OrgNameChangeSection /> <OrgNameChangeSection />
<OrgSlugChangeSection />
<OrgIncidentContactsSection /> <OrgIncidentContactsSection />
{membership && membership.role === "admin" && <OrgDeleteSection />} {membership && membership.role === "admin" && <OrgDeleteSection />}
</div> </div>
); );
}; };
@@ -7,7 +7,7 @@ import { useNotificationContext } from "@app/components/context/Notifications/No
import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input } from "@app/components/v2"; import { Button, FormControl, Input } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useRenameOrg } from "@app/hooks/api"; import { useUpdateOrg } from "@app/hooks/api";
const formSchema = yup.object({ const formSchema = yup.object({
name: yup.string().required().label("Project Name") name: yup.string().required().label("Project Name")
@@ -21,7 +21,7 @@ export const OrgNameChangeSection = (): JSX.Element => {
const { handleSubmit, control, reset } = useForm<FormData>({ const { handleSubmit, control, reset } = useForm<FormData>({
resolver: yupResolver(formSchema) resolver: yupResolver(formSchema)
}); });
const { mutateAsync, isLoading } = useRenameOrg(); const { mutateAsync, isLoading } = useUpdateOrg();
useEffect(() => { useEffect(() => {
if (currentOrg) { if (currentOrg) {
@@ -34,7 +34,7 @@ export const OrgNameChangeSection = (): JSX.Element => {
if (!currentOrg?.id) return; if (!currentOrg?.id) return;
if (name === "") return; if (name === "") return;
await mutateAsync({ orgId: currentOrg?.id, newOrgName: name }); await mutateAsync({ orgId: currentOrg?.id, name });
createNotification({ createNotification({
text: "Successfully renamed organization", text: "Successfully renamed organization",
type: "success" type: "success"
@@ -53,7 +53,7 @@ export const OrgNameChangeSection = (): JSX.Element => {
onSubmit={handleSubmit(onFormSubmit)} onSubmit={handleSubmit(onFormSubmit)}
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4" className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
> >
<p className="mb-4 text-xl font-semibold text-mineshaft-100">Name</p> <p className="mb-4 text-xl font-semibold text-mineshaft-100">Organization Name</p>
<div className="mb-2 max-w-md"> <div className="mb-2 max-w-md">
<Controller <Controller
defaultValue="" defaultValue=""
@@ -0,0 +1,84 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useUpdateOrg } from "@app/hooks/api";
const formSchema = yup.object({
slug: yup.string().required().label("Project Slug")
});
type FormData = yup.InferType<typeof formSchema>;
export const OrgSlugChangeSection = (): JSX.Element => {
const { currentOrg } = useOrganization();
const { createNotification } = useNotificationContext();
const { handleSubmit, control, reset } = useForm<FormData>({
resolver: yupResolver(formSchema)
});
const { mutateAsync, isLoading } = useUpdateOrg();
useEffect(() => {
if (currentOrg) {
reset({ slug: currentOrg.slug });
}
}, [currentOrg]);
const onFormSubmit = async ({ slug }: FormData) => {
try {
if (!currentOrg?.id) return;
if (slug === "") return;
await mutateAsync({ orgId: currentOrg?.id, slug });
createNotification({
text: "Successfully updated organization slug",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to update organization slug",
type: "error"
});
}
};
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<p className="mb-4 text-xl font-semibold text-mineshaft-100">Organization Slug</p>
<div className="mb-2 max-w-md">
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input placeholder="acme" {...field} />
</FormControl>
)}
control={control}
name="slug"
/>
</div>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Settings}>
{(isAllowed) => (
<Button
isLoading={isLoading}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="submit"
>
Save
</Button>
)}
</OrgPermissionCan>
</form>
);
}
@@ -0,0 +1 @@
export { OrgSlugChangeSection } from "./OrgSlugChangeSection";