mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge pull request #3518 from akhilmhdh/fix/email-ambigious
fix: email casing conflicts
This commit is contained in:
@@ -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
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,7 +145,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
|
|||||||
|
|
||||||
const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({
|
const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({
|
||||||
externalId: profile.nameID,
|
externalId: profile.nameID,
|
||||||
email,
|
email: email.toLowerCase(),
|
||||||
firstName,
|
firstName,
|
||||||
lastName: lastName as string,
|
lastName: lastName as string,
|
||||||
relayState: (req.body as { RelayState?: string }).RelayState,
|
relayState: (req.body as { RelayState?: string }).RelayState,
|
||||||
|
|||||||
@@ -111,9 +111,9 @@ export const groupDALFactory = (db: TDbClient) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
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) {
|
} else if (username) {
|
||||||
void query.andWhere(`${TableName.Users}.username`, "ilike", `%${username}%`);
|
void query.andWhereRaw(`lower("${TableName.Users}"."username") ilike ?`, `%${username}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal";
|
import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal";
|
||||||
|
|
||||||
type TGroupServiceFactoryDep = {
|
type TGroupServiceFactoryDep = {
|
||||||
userDAL: Pick<TUserDALFactory, "find" | "findUserEncKeyByUserIdsBatch" | "transaction" | "findOne">;
|
userDAL: Pick<TUserDALFactory, "find" | "findUserEncKeyByUserIdsBatch" | "transaction" | "findUserByUsername">;
|
||||||
groupDAL: Pick<
|
groupDAL: Pick<
|
||||||
TGroupDALFactory,
|
TGroupDALFactory,
|
||||||
"create" | "findOne" | "update" | "delete" | "findAllGroupPossibleMembers" | "findById" | "transaction"
|
"create" | "findOne" | "update" | "delete" | "findAllGroupPossibleMembers" | "findById" | "transaction"
|
||||||
@@ -380,7 +380,10 @@ export const groupServiceFactory = ({
|
|||||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
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}` });
|
if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` });
|
||||||
|
|
||||||
const users = await addUsersToGroupByUserIds({
|
const users = await addUsersToGroupByUserIds({
|
||||||
@@ -461,7 +464,10 @@ export const groupServiceFactory = ({
|
|||||||
details: { missingPermissions: permissionBoundary.missingPermissions }
|
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}` });
|
if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` });
|
||||||
|
|
||||||
const users = await removeUsersFromGroupByUserIds({
|
const users = await removeUsersFromGroupByUserIds({
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ export const ldapConfigServiceFactory = ({
|
|||||||
if (serverCfg.trustLdapEmails) {
|
if (serverCfg.trustLdapEmails) {
|
||||||
newUser = await userDAL.findOne(
|
newUser = await userDAL.findOne(
|
||||||
{
|
{
|
||||||
email,
|
email: email.toLowerCase(),
|
||||||
isEmailVerified: true
|
isEmailVerified: true
|
||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
@@ -391,8 +391,8 @@ export const ldapConfigServiceFactory = ({
|
|||||||
const uniqueUsername = await normalizeUsername(username, userDAL);
|
const uniqueUsername = await normalizeUsername(username, userDAL);
|
||||||
newUser = await userDAL.create(
|
newUser = await userDAL.create(
|
||||||
{
|
{
|
||||||
username: serverCfg.trustLdapEmails ? email : uniqueUsername,
|
username: serverCfg.trustLdapEmails ? email.toLowerCase() : uniqueUsername,
|
||||||
email,
|
email: email.toLowerCase(),
|
||||||
isEmailVerified: serverCfg.trustLdapEmails,
|
isEmailVerified: serverCfg.trustLdapEmails,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
@@ -429,7 +429,7 @@ export const ldapConfigServiceFactory = ({
|
|||||||
await orgMembershipDAL.create(
|
await orgMembershipDAL.create(
|
||||||
{
|
{
|
||||||
userId: newUser.id,
|
userId: newUser.id,
|
||||||
inviteEmail: email,
|
inviteEmail: email.toLowerCase(),
|
||||||
orgId,
|
orgId,
|
||||||
role,
|
role,
|
||||||
roleId,
|
roleId,
|
||||||
|
|||||||
@@ -171,8 +171,8 @@ export const oidcConfigServiceFactory = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const oidcLogin = async ({
|
const oidcLogin = async ({
|
||||||
externalId,
|
|
||||||
email,
|
email,
|
||||||
|
externalId,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
orgId,
|
orgId,
|
||||||
@@ -717,7 +717,7 @@ export const oidcConfigServiceFactory = ({
|
|||||||
const groups = typeof claims.groups === "string" ? [claims.groups] : (claims.groups as string[] | undefined);
|
const groups = typeof claims.groups === "string" ? [claims.groups] : (claims.groups as string[] | undefined);
|
||||||
|
|
||||||
oidcLogin({
|
oidcLogin({
|
||||||
email: claims.email,
|
email: claims.email.toLowerCase(),
|
||||||
externalId: claims.sub,
|
externalId: claims.sub,
|
||||||
firstName: claims.given_name ?? "",
|
firstName: claims.given_name ?? "",
|
||||||
lastName: claims.family_name ?? "",
|
lastName: claims.family_name ?? "",
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ export const scimServiceFactory = ({
|
|||||||
orgMembership = await orgMembershipDAL.create(
|
orgMembership = await orgMembershipDAL.create(
|
||||||
{
|
{
|
||||||
userId: userAlias.userId,
|
userId: userAlias.userId,
|
||||||
inviteEmail: email,
|
inviteEmail: email.toLowerCase(),
|
||||||
orgId,
|
orgId,
|
||||||
role,
|
role,
|
||||||
roleId,
|
roleId,
|
||||||
@@ -364,7 +364,7 @@ export const scimServiceFactory = ({
|
|||||||
if (trustScimEmails) {
|
if (trustScimEmails) {
|
||||||
user = await userDAL.findOne(
|
user = await userDAL.findOne(
|
||||||
{
|
{
|
||||||
email,
|
email: email.toLowerCase(),
|
||||||
isEmailVerified: true
|
isEmailVerified: true
|
||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
@@ -379,8 +379,8 @@ export const scimServiceFactory = ({
|
|||||||
);
|
);
|
||||||
user = await userDAL.create(
|
user = await userDAL.create(
|
||||||
{
|
{
|
||||||
username: trustScimEmails ? email : uniqueUsername,
|
username: trustScimEmails ? email.toLowerCase() : uniqueUsername,
|
||||||
email,
|
email: email.toLowerCase(),
|
||||||
isEmailVerified: trustScimEmails,
|
isEmailVerified: trustScimEmails,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
@@ -396,7 +396,7 @@ export const scimServiceFactory = ({
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
aliasType,
|
aliasType,
|
||||||
externalId,
|
externalId,
|
||||||
emails: email ? [email] : [],
|
emails: email ? [email.toLowerCase()] : [],
|
||||||
orgId
|
orgId
|
||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
@@ -418,7 +418,7 @@ export const scimServiceFactory = ({
|
|||||||
orgMembership = await orgMembershipDAL.create(
|
orgMembership = await orgMembershipDAL.create(
|
||||||
{
|
{
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
inviteEmail: email,
|
inviteEmail: email.toLowerCase(),
|
||||||
orgId,
|
orgId,
|
||||||
role,
|
role,
|
||||||
roleId,
|
roleId,
|
||||||
@@ -529,7 +529,7 @@ export const scimServiceFactory = ({
|
|||||||
membership.userId,
|
membership.userId,
|
||||||
{
|
{
|
||||||
firstName: scimUser.name.givenName,
|
firstName: scimUser.name.givenName,
|
||||||
email: scimUser.emails[0].value,
|
email: scimUser.emails[0].value.toLowerCase(),
|
||||||
lastName: scimUser.name.familyName,
|
lastName: scimUser.name.familyName,
|
||||||
isEmailVerified: hasEmailChanged ? trustScimEmails : undefined
|
isEmailVerified: hasEmailChanged ? trustScimEmails : undefined
|
||||||
},
|
},
|
||||||
@@ -606,7 +606,7 @@ export const scimServiceFactory = ({
|
|||||||
membership.userId,
|
membership.userId,
|
||||||
{
|
{
|
||||||
firstName,
|
firstName,
|
||||||
email,
|
email: email?.toLowerCase(),
|
||||||
lastName,
|
lastName,
|
||||||
isEmailVerified:
|
isEmailVerified:
|
||||||
org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails
|
org.orgAuthMethod === OrgAuthMethod.OIDC ? serverCfg.trustOidcEmails : serverCfg.trustSamlEmails
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Knex } from "knex";
|
import { Knex } from "knex";
|
||||||
import { Compare, Filter, parse } from "scim2-parse-filter";
|
import { Compare, Filter, parse } from "scim2-parse-filter";
|
||||||
|
|
||||||
|
import { TableName } from "@app/db/schemas";
|
||||||
|
|
||||||
const appendParentToGroupingOperator = (parentPath: string, filter: Filter) => {
|
const appendParentToGroupingOperator = (parentPath: string, filter: Filter) => {
|
||||||
if (filter.op !== "[]" && filter.op !== "and" && filter.op !== "or" && filter.op !== "not") {
|
if (filter.op !== "[]" && filter.op !== "and" && filter.op !== "or" && filter.op !== "not") {
|
||||||
return { ...filter, attrPath: `${parentPath}.${(filter as Compare).attrPath}` };
|
return { ...filter, attrPath: `${parentPath}.${(filter as Compare).attrPath}` };
|
||||||
@@ -27,8 +29,12 @@ const processDynamicQuery = (
|
|||||||
const { scimFilterAst, query } = stack.pop()!;
|
const { scimFilterAst, query } = stack.pop()!;
|
||||||
switch (scimFilterAst.op) {
|
switch (scimFilterAst.op) {
|
||||||
case "eq": {
|
case "eq": {
|
||||||
|
let sanitizedValue = scimFilterAst.compValue;
|
||||||
const attrPath = getAttributeField(scimFilterAst.attrPath);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case "pr": {
|
case "pr": {
|
||||||
@@ -62,18 +68,30 @@ const processDynamicQuery = (
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "ew": {
|
case "ew": {
|
||||||
|
let sanitizedValue = scimFilterAst.compValue;
|
||||||
const attrPath = getAttributeField(scimFilterAst.attrPath);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case "co": {
|
case "co": {
|
||||||
|
let sanitizedValue = scimFilterAst.compValue;
|
||||||
const attrPath = getAttributeField(scimFilterAst.attrPath);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case "ne": {
|
case "ne": {
|
||||||
|
let sanitizedValue = scimFilterAst.compValue;
|
||||||
const attrPath = getAttributeField(scimFilterAst.attrPath);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case "and": {
|
case "and": {
|
||||||
|
|||||||
@@ -625,7 +625,6 @@ export const registerRoutes = async (
|
|||||||
|
|
||||||
const userService = userServiceFactory({
|
const userService = userServiceFactory({
|
||||||
userDAL,
|
userDAL,
|
||||||
userAliasDAL,
|
|
||||||
orgMembershipDAL,
|
orgMembershipDAL,
|
||||||
tokenService,
|
tokenService,
|
||||||
permissionService,
|
permissionService,
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
schema: {
|
schema: {
|
||||||
body: z.object({
|
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(),
|
organizationId: z.string().trim(),
|
||||||
projects: z
|
projects: z
|
||||||
.object({
|
.object({
|
||||||
@@ -115,7 +120,11 @@ export const registerInviteOrgRouter = async (server: FastifyZodProvider) => {
|
|||||||
},
|
},
|
||||||
schema: {
|
schema: {
|
||||||
body: z.object({
|
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(),
|
organizationId: z.string().trim(),
|
||||||
code: 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({
|
server.route({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: "/private-key",
|
url: "/private-key",
|
||||||
|
|||||||
@@ -27,8 +27,19 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
|
|||||||
projectId: z.string().describe(PROJECT_USERS.INVITE_MEMBER.projectId)
|
projectId: z.string().describe(PROJECT_USERS.INVITE_MEMBER.projectId)
|
||||||
}),
|
}),
|
||||||
body: z.object({
|
body: z.object({
|
||||||
emails: z.string().email().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.emails),
|
emails: z
|
||||||
usernames: z.string().array().default([]).describe(PROJECT_USERS.INVITE_MEMBER.usernames),
|
.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)
|
roleSlugs: z.string().array().min(1).optional().describe(PROJECT_USERS.INVITE_MEMBER.roleSlugs)
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
@@ -92,8 +103,19 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
|
|||||||
projectId: z.string().describe(PROJECT_USERS.REMOVE_MEMBER.projectId)
|
projectId: z.string().describe(PROJECT_USERS.REMOVE_MEMBER.projectId)
|
||||||
}),
|
}),
|
||||||
body: z.object({
|
body: z.object({
|
||||||
emails: z.string().email().array().default([]).describe(PROJECT_USERS.REMOVE_MEMBER.emails),
|
emails: z
|
||||||
usernames: z.string().array().default([]).describe(PROJECT_USERS.REMOVE_MEMBER.usernames)
|
.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: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
|
|||||||
@@ -199,9 +199,12 @@ export const authLoginServiceFactory = ({
|
|||||||
providerAuthToken,
|
providerAuthToken,
|
||||||
clientPublicKey
|
clientPublicKey
|
||||||
}: TLoginGenServerPublicKeyDTO) => {
|
}: TLoginGenServerPublicKeyDTO) => {
|
||||||
const userEnc = await userDAL.findUserEncKeyByUsername({
|
// akhilmhdh: case sensitive email resolution
|
||||||
|
const usersByUsername = await userDAL.findUserEncKeyByUsername({
|
||||||
username: email
|
username: email
|
||||||
});
|
});
|
||||||
|
const userEnc =
|
||||||
|
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
|
||||||
|
|
||||||
const serverCfg = await getServerCfg();
|
const serverCfg = await getServerCfg();
|
||||||
|
|
||||||
@@ -250,9 +253,12 @@ export const authLoginServiceFactory = ({
|
|||||||
}: TLoginClientProofDTO) => {
|
}: TLoginClientProofDTO) => {
|
||||||
const appCfg = getConfig();
|
const appCfg = getConfig();
|
||||||
|
|
||||||
const userEnc = await userDAL.findUserEncKeyByUsername({
|
// akhilmhdh: case sensitive email resolution
|
||||||
|
const usersByUsername = await userDAL.findUserEncKeyByUsername({
|
||||||
username: email
|
username: email
|
||||||
});
|
});
|
||||||
|
const userEnc =
|
||||||
|
usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
|
||||||
if (!userEnc) throw new Error("Failed to find user");
|
if (!userEnc) throw new Error("Failed to find user");
|
||||||
const user = await userDAL.findById(userEnc.userId);
|
const user = await userDAL.findById(userEnc.userId);
|
||||||
const cfg = getConfig();
|
const cfg = getConfig();
|
||||||
@@ -649,10 +655,12 @@ export const authLoginServiceFactory = ({
|
|||||||
* OAuth2 login for google,github, and other oauth2 provider
|
* OAuth2 login for google,github, and other oauth2 provider
|
||||||
* */
|
* */
|
||||||
const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => {
|
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();
|
const serverCfg = await getServerCfg();
|
||||||
|
|
||||||
if (serverCfg.enabledLoginMethods) {
|
if (serverCfg.enabledLoginMethods && user) {
|
||||||
switch (authMethod) {
|
switch (authMethod) {
|
||||||
case AuthMethod.GITHUB: {
|
case AuthMethod.GITHUB: {
|
||||||
if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITHUB)) {
|
if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITHUB)) {
|
||||||
@@ -715,8 +723,8 @@ export const authLoginServiceFactory = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
user = await userDAL.create({
|
user = await userDAL.create({
|
||||||
username: email,
|
username: email.trim().toLowerCase(),
|
||||||
email,
|
email: email.trim().toLowerCase(),
|
||||||
isEmailVerified: true,
|
isEmailVerified: true,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
@@ -814,11 +822,14 @@ export const authLoginServiceFactory = ({
|
|||||||
? decodedProviderToken.orgId
|
? decodedProviderToken.orgId
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const userEnc = await userDAL.findUserEncKeyByUsername({
|
// akhilmhdh: case sensitive email resolution
|
||||||
|
const usersByUsername = await userDAL.findUserEncKeyByUsername({
|
||||||
username: email
|
username: email
|
||||||
});
|
});
|
||||||
if (!userEnc) throw new BadRequestError({ message: "Invalid token" });
|
const userEnc =
|
||||||
if (!userEnc.serverEncryptedPrivateKey)
|
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." });
|
throw new BadRequestError({ message: "Key handoff incomplete. Please try logging in again." });
|
||||||
|
|
||||||
const token = await generateUserTokens({
|
const token = await generateUserTokens({
|
||||||
|
|||||||
@@ -121,7 +121,10 @@ export const authPaswordServiceFactory = ({
|
|||||||
*/
|
*/
|
||||||
const sendPasswordResetEmail = async (email: string) => {
|
const sendPasswordResetEmail = async (email: string) => {
|
||||||
const sendEmail = async () => {
|
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) {
|
if (user && user.isAccepted) {
|
||||||
const cfg = getConfig();
|
const cfg = getConfig();
|
||||||
@@ -152,7 +155,10 @@ export const authPaswordServiceFactory = ({
|
|||||||
* */
|
* */
|
||||||
const verifyPasswordResetEmail = async (email: string, code: string) => {
|
const verifyPasswordResetEmail = async (email: string, code: string) => {
|
||||||
const cfg = getConfig();
|
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);
|
const userEnc = await userDAL.findUserEncKeyByUserId(user.id);
|
||||||
|
|
||||||
|
|||||||
@@ -73,18 +73,27 @@ export const authSignupServiceFactory = ({
|
|||||||
}: TAuthSignupDep) => {
|
}: TAuthSignupDep) => {
|
||||||
// first step of signup. create user and send email
|
// first step of signup. create user and send email
|
||||||
const beginEmailSignupProcess = async (email: string) => {
|
const beginEmailSignupProcess = async (email: string) => {
|
||||||
const isEmailInvalid = await isDisposableEmail(email);
|
const sanitizedEmail = email.trim().toLowerCase();
|
||||||
|
const isEmailInvalid = await isDisposableEmail(sanitizedEmail);
|
||||||
if (isEmailInvalid) {
|
if (isEmailInvalid) {
|
||||||
throw new Error("Provided a disposable email");
|
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) {
|
if (user && user.isAccepted) {
|
||||||
// TODO(akhilmhdh-pg): copy as old one. this needs to be changed due to security issues
|
// 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) {
|
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");
|
if (!user) throw new Error("Failed to create user");
|
||||||
|
|
||||||
@@ -96,7 +105,7 @@ export const authSignupServiceFactory = ({
|
|||||||
await smtpService.sendMail({
|
await smtpService.sendMail({
|
||||||
template: SmtpTemplates.SignupEmailVerification,
|
template: SmtpTemplates.SignupEmailVerification,
|
||||||
subjectLine: "Infisical confirmation code",
|
subjectLine: "Infisical confirmation code",
|
||||||
recipients: [user.email as string],
|
recipients: [sanitizedEmail],
|
||||||
substitutions: {
|
substitutions: {
|
||||||
code: token
|
code: token
|
||||||
}
|
}
|
||||||
@@ -104,11 +113,15 @@ export const authSignupServiceFactory = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const verifyEmailSignup = async (email: string, code: string) => {
|
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)) {
|
if (!user || (user && user.isAccepted)) {
|
||||||
// TODO(akhilmhdh): copy as old one. this needs to be changed due to security issues
|
// 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");
|
throw new Error("Failed to send verification code for complete account");
|
||||||
}
|
}
|
||||||
|
|
||||||
const appCfg = getConfig();
|
const appCfg = getConfig();
|
||||||
await tokenService.validateTokenForUser({
|
await tokenService.validateTokenForUser({
|
||||||
type: TokenType.TOKEN_EMAIL_CONFIRMATION,
|
type: TokenType.TOKEN_EMAIL_CONFIRMATION,
|
||||||
@@ -153,12 +166,15 @@ export const authSignupServiceFactory = ({
|
|||||||
authorization,
|
authorization,
|
||||||
useDefaultOrg
|
useDefaultOrg
|
||||||
}: TCompleteAccountSignupDTO) => {
|
}: TCompleteAccountSignupDTO) => {
|
||||||
|
const sanitizedEmail = email.trim().toLowerCase();
|
||||||
const appCfg = getConfig();
|
const appCfg = getConfig();
|
||||||
const serverCfg = await getServerCfg();
|
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)) {
|
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;
|
let organizationId: string | null = null;
|
||||||
@@ -315,7 +331,7 @@ export const authSignupServiceFactory = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatedMembersips = await orgDAL.updateMembership(
|
const updatedMembersips = await orgDAL.updateMembership(
|
||||||
{ inviteEmail: email, status: OrgMembershipStatus.Invited },
|
{ inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited },
|
||||||
{ userId: user.id, status: OrgMembershipStatus.Accepted }
|
{ userId: user.id, status: OrgMembershipStatus.Accepted }
|
||||||
);
|
);
|
||||||
const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))];
|
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
|
* User signup flow when they are invited to join the org
|
||||||
* */
|
* */
|
||||||
const completeAccountInvite = async ({
|
const completeAccountInvite = async ({
|
||||||
|
email,
|
||||||
ip,
|
ip,
|
||||||
salt,
|
salt,
|
||||||
email,
|
|
||||||
password,
|
password,
|
||||||
verifier,
|
verifier,
|
||||||
firstName,
|
firstName,
|
||||||
@@ -399,7 +415,10 @@ export const authSignupServiceFactory = ({
|
|||||||
encryptedPrivateKeyTag,
|
encryptedPrivateKeyTag,
|
||||||
authorization
|
authorization
|
||||||
}: TCompleteAccountInviteDTO) => {
|
}: 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)) {
|
if (!user || (user && user.isAccepted)) {
|
||||||
throw new Error("Failed to complete account for complete user");
|
throw new Error("Failed to complete account for complete user");
|
||||||
}
|
}
|
||||||
@@ -407,7 +426,7 @@ export const authSignupServiceFactory = ({
|
|||||||
validateSignUpAuthorization(authorization, user.id);
|
validateSignUpAuthorization(authorization, user.id);
|
||||||
|
|
||||||
const [orgMembership] = await orgDAL.findMembership({
|
const [orgMembership] = await orgDAL.findMembership({
|
||||||
inviteEmail: email,
|
inviteEmail: sanitizedEmail,
|
||||||
status: OrgMembershipStatus.Invited
|
status: OrgMembershipStatus.Invited
|
||||||
});
|
});
|
||||||
if (!orgMembership)
|
if (!orgMembership)
|
||||||
@@ -454,7 +473,7 @@ export const authSignupServiceFactory = ({
|
|||||||
const serverGeneratedPrivateKey = await getUserPrivateKey(serverGeneratedPassword, {
|
const serverGeneratedPrivateKey = await getUserPrivateKey(serverGeneratedPassword, {
|
||||||
...systemGeneratedUserEncryptionKey
|
...systemGeneratedUserEncryptionKey
|
||||||
});
|
});
|
||||||
const encKeys = await generateUserSrpKeys(email, password, {
|
const encKeys = await generateUserSrpKeys(sanitizedEmail, password, {
|
||||||
publicKey: systemGeneratedUserEncryptionKey.publicKey,
|
publicKey: systemGeneratedUserEncryptionKey.publicKey,
|
||||||
privateKey: serverGeneratedPrivateKey
|
privateKey: serverGeneratedPrivateKey
|
||||||
});
|
});
|
||||||
@@ -505,7 +524,7 @@ export const authSignupServiceFactory = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatedMembersips = await orgDAL.updateMembership(
|
const updatedMembersips = await orgDAL.updateMembership(
|
||||||
{ inviteEmail: email, status: OrgMembershipStatus.Invited },
|
{ inviteEmail: sanitizedEmail, status: OrgMembershipStatus.Invited },
|
||||||
{ userId: us.id, status: OrgMembershipStatus.Accepted },
|
{ userId: us.id, status: OrgMembershipStatus.Accepted },
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -827,7 +827,11 @@ export const orgServiceFactory = ({
|
|||||||
const users: Pick<TUsers, "id" | "firstName" | "lastName" | "email" | "username">[] = [];
|
const users: Pick<TUsers, "id" | "firstName" | "lastName" | "email" | "username">[] = [];
|
||||||
|
|
||||||
for await (const inviteeEmail of inviteeEmails) {
|
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 the user doesn't exist we create the user with the email
|
||||||
if (!inviteeUser) {
|
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
|
* magic link and issue a temporary signup token for user to complete setting up their account
|
||||||
*/
|
*/
|
||||||
const verifyUserToOrg = async ({ orgId, email, code }: TVerifyUserToOrgDTO) => {
|
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) {
|
if (!user) {
|
||||||
throw new NotFoundError({ message: "User not found" });
|
throw new NotFoundError({ message: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [orgMembership] = await orgDAL.findMembership({
|
const [orgMembership] = await orgDAL.findMembership({
|
||||||
[`${TableName.OrgMembership}.userId` as "userId"]: user.id,
|
[`${TableName.OrgMembership}.userId` as "userId"]: user.id,
|
||||||
status: OrgMembershipStatus.Invited,
|
status: OrgMembershipStatus.Invited,
|
||||||
|
|||||||
@@ -257,8 +257,8 @@ export const superAdminServiceFactory = ({
|
|||||||
const adminSignUp = async ({
|
const adminSignUp = async ({
|
||||||
lastName,
|
lastName,
|
||||||
firstName,
|
firstName,
|
||||||
salt,
|
|
||||||
email,
|
email,
|
||||||
|
salt,
|
||||||
password,
|
password,
|
||||||
verifier,
|
verifier,
|
||||||
publicKey,
|
publicKey,
|
||||||
@@ -272,7 +272,8 @@ export const superAdminServiceFactory = ({
|
|||||||
userAgent
|
userAgent
|
||||||
}: TAdminSignUpDTO) => {
|
}: TAdminSignUpDTO) => {
|
||||||
const appCfg = getConfig();
|
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" });
|
if (existingUser) throw new BadRequestError({ name: "Admin sign up", message: "User already exists" });
|
||||||
|
|
||||||
const privateKey = await getUserPrivateKey(password, {
|
const privateKey = await getUserPrivateKey(password, {
|
||||||
@@ -292,8 +293,8 @@ export const superAdminServiceFactory = ({
|
|||||||
{
|
{
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
username: email,
|
username: sanitizedEmail,
|
||||||
email,
|
email: sanitizedEmail,
|
||||||
superAdmin: true,
|
superAdmin: true,
|
||||||
isGhost: false,
|
isGhost: false,
|
||||||
isAccepted: true,
|
isAccepted: true,
|
||||||
@@ -348,12 +349,13 @@ export const superAdminServiceFactory = ({
|
|||||||
|
|
||||||
const bootstrapInstance = async ({ email, password, organizationName }: TAdminBootstrapInstanceDTO) => {
|
const bootstrapInstance = async ({ email, password, organizationName }: TAdminBootstrapInstanceDTO) => {
|
||||||
const appCfg = getConfig();
|
const appCfg = getConfig();
|
||||||
|
const sanitizedEmail = email.trim().toLowerCase();
|
||||||
const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID);
|
const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID);
|
||||||
if (serverCfg?.initialized) {
|
if (serverCfg?.initialized) {
|
||||||
throw new BadRequestError({ message: "Instance has already been set up" });
|
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" });
|
if (existingUser) throw new BadRequestError({ name: "Instance initialization", message: "User already exists" });
|
||||||
|
|
||||||
const userInfo = await userDAL.transaction(async (tx) => {
|
const userInfo = await userDAL.transaction(async (tx) => {
|
||||||
@@ -361,8 +363,8 @@ export const superAdminServiceFactory = ({
|
|||||||
{
|
{
|
||||||
firstName: "Admin",
|
firstName: "Admin",
|
||||||
lastName: "User",
|
lastName: "User",
|
||||||
username: email,
|
username: sanitizedEmail,
|
||||||
email,
|
email: sanitizedEmail,
|
||||||
superAdmin: true,
|
superAdmin: true,
|
||||||
isGhost: false,
|
isGhost: false,
|
||||||
isAccepted: true,
|
isAccepted: true,
|
||||||
@@ -372,7 +374,7 @@ export const superAdminServiceFactory = ({
|
|||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
const { tag, encoding, ciphertext, iv } = infisicalSymmetricEncypt(password);
|
const { tag, encoding, ciphertext, iv } = infisicalSymmetricEncypt(password);
|
||||||
const encKeys = await generateUserSrpKeys(email, password);
|
const encKeys = await generateUserSrpKeys(sanitizedEmail, password);
|
||||||
|
|
||||||
const userEnc = await userDAL.createUserEncryption(
|
const userEnc = await userDAL.createUserEncryption(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,16 +8,18 @@ import {
|
|||||||
TUserEncryptionKeys,
|
TUserEncryptionKeys,
|
||||||
TUserEncryptionKeysInsert,
|
TUserEncryptionKeysInsert,
|
||||||
TUserEncryptionKeysUpdate,
|
TUserEncryptionKeysUpdate,
|
||||||
TUsers
|
TUsers,
|
||||||
|
UsersSchema
|
||||||
} from "@app/db/schemas";
|
} from "@app/db/schemas";
|
||||||
import { DatabaseError } from "@app/lib/errors";
|
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 type TUserDALFactory = ReturnType<typeof userDALFactory>;
|
||||||
|
|
||||||
export const userDALFactory = (db: TDbClient) => {
|
export const userDALFactory = (db: TDbClient) => {
|
||||||
const userOrm = ormify(db, TableName.Users);
|
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 ({
|
const getUsersByFilter = async ({
|
||||||
limit,
|
limit,
|
||||||
@@ -41,7 +43,7 @@ export const userDALFactory = (db: TDbClient) => {
|
|||||||
.whereILike("email", `%${searchTerm}%`)
|
.whereILike("email", `%${searchTerm}%`)
|
||||||
.orWhereILike("firstName", `%${searchTerm}%`)
|
.orWhereILike("firstName", `%${searchTerm}%`)
|
||||||
.orWhereILike("lastName", `%${searchTerm}%`)
|
.orWhereILike("lastName", `%${searchTerm}%`)
|
||||||
.orWhereLike("username", `%${searchTerm}%`);
|
.orWhereRaw('lower("username") like ?', `%${searchTerm}%`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,12 +67,11 @@ export const userDALFactory = (db: TDbClient) => {
|
|||||||
try {
|
try {
|
||||||
return await db
|
return await db
|
||||||
.replicaNode()(TableName.Users)
|
.replicaNode()(TableName.Users)
|
||||||
|
.whereRaw('lower("username") = :username', { username: username.toLowerCase() })
|
||||||
.where({
|
.where({
|
||||||
username,
|
|
||||||
isGhost: false
|
isGhost: false
|
||||||
})
|
})
|
||||||
.join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`)
|
.join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`);
|
||||||
.first();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new DatabaseError({ error, name: "Find user enc by email" });
|
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
|
// USER ACTION FUNCTIONS
|
||||||
// ---------------------
|
// ---------------------
|
||||||
const findOneUserAction = (filter: TUserActionsUpdate, tx?: Knex) => {
|
const findOneUserAction = (filter: TUserActionsUpdate, tx?: Knex) => {
|
||||||
@@ -200,6 +233,7 @@ export const userDALFactory = (db: TDbClient) => {
|
|||||||
createUserEncryption,
|
createUserEncryption,
|
||||||
findOneUserAction,
|
findOneUserAction,
|
||||||
createUserAction,
|
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 { TokenType } from "@app/services/auth-token/auth-token-types";
|
||||||
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
|
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
|
||||||
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
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 { AuthMethod } from "../auth/auth-type";
|
||||||
import { TGroupProjectDALFactory } from "../group-project/group-project-dal";
|
import { TGroupProjectDALFactory } from "../group-project/group-project-dal";
|
||||||
@@ -21,7 +20,7 @@ type TUserServiceFactoryDep = {
|
|||||||
userDAL: Pick<
|
userDAL: Pick<
|
||||||
TUserDALFactory,
|
TUserDALFactory,
|
||||||
| "find"
|
| "find"
|
||||||
| "findOne"
|
| "findUserByUsername"
|
||||||
| "findById"
|
| "findById"
|
||||||
| "transaction"
|
| "transaction"
|
||||||
| "updateById"
|
| "updateById"
|
||||||
@@ -31,8 +30,8 @@ type TUserServiceFactoryDep = {
|
|||||||
| "createUserAction"
|
| "createUserAction"
|
||||||
| "findUserEncKeyByUserId"
|
| "findUserEncKeyByUserId"
|
||||||
| "delete"
|
| "delete"
|
||||||
|
| "findAllMyAccounts"
|
||||||
>;
|
>;
|
||||||
userAliasDAL: Pick<TUserAliasDALFactory, "find" | "insertMany">;
|
|
||||||
groupProjectDAL: Pick<TGroupProjectDALFactory, "findByUserId">;
|
groupProjectDAL: Pick<TGroupProjectDALFactory, "findByUserId">;
|
||||||
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find" | "insertMany" | "findOne" | "updateById">;
|
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find" | "insertMany" | "findOne" | "updateById">;
|
||||||
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser">;
|
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser">;
|
||||||
@@ -45,7 +44,6 @@ export type TUserServiceFactory = ReturnType<typeof userServiceFactory>;
|
|||||||
|
|
||||||
export const userServiceFactory = ({
|
export const userServiceFactory = ({
|
||||||
userDAL,
|
userDAL,
|
||||||
userAliasDAL,
|
|
||||||
orgMembershipDAL,
|
orgMembershipDAL,
|
||||||
projectMembershipDAL,
|
projectMembershipDAL,
|
||||||
groupProjectDAL,
|
groupProjectDAL,
|
||||||
@@ -54,8 +52,11 @@ export const userServiceFactory = ({
|
|||||||
permissionService
|
permissionService
|
||||||
}: TUserServiceFactoryDep) => {
|
}: TUserServiceFactoryDep) => {
|
||||||
const sendEmailVerificationCode = async (username: string) => {
|
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) throw new NotFoundError({ name: `User with username '${username}' not found` });
|
||||||
|
|
||||||
if (!user.email)
|
if (!user.email)
|
||||||
throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" });
|
throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" });
|
||||||
if (user.isEmailVerified)
|
if (user.isEmailVerified)
|
||||||
@@ -77,7 +78,10 @@ export const userServiceFactory = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const verifyEmailVerificationCode = async (username: string, code: string) => {
|
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) throw new NotFoundError({ name: `User with username '${username}' not found` });
|
||||||
if (!user.email)
|
if (!user.email)
|
||||||
throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" });
|
throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" });
|
||||||
@@ -90,84 +94,8 @@ export const userServiceFactory = ({
|
|||||||
code
|
code
|
||||||
});
|
});
|
||||||
|
|
||||||
const { email } = user;
|
await userDAL.updateById(user.id, {
|
||||||
|
isEmailVerified: true
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -212,6 +140,23 @@ export const userServiceFactory = ({
|
|||||||
return updatedUser;
|
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 getMe = async (userId: string) => {
|
||||||
const user = await userDAL.findUserEncKeyByUserId(userId);
|
const user = await userDAL.findUserEncKeyByUserId(userId);
|
||||||
if (!user) throw new NotFoundError({ message: `User with ID '${userId}' not found`, name: "GetMe" });
|
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 listUserGroups = async ({ username, actorOrgId, actor, actorId, actorAuthMethod }: TListUserGroupsDTO) => {
|
||||||
const user = await userDAL.findOne({
|
// akhilmhdh: case sensitive email resolution
|
||||||
username
|
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.
|
// 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) {
|
if (user.id !== actorId) {
|
||||||
@@ -346,7 +293,9 @@ export const userServiceFactory = ({
|
|||||||
getUserAction,
|
getUserAction,
|
||||||
unlockUser,
|
unlockUser,
|
||||||
getUserPrivateKey,
|
getUserPrivateKey,
|
||||||
|
getAllMyAccounts,
|
||||||
getUserProjectFavorites,
|
getUserProjectFavorites,
|
||||||
|
removeMyDuplicateAccounts,
|
||||||
updateUserProjectFavorites
|
updateUserProjectFavorites
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export {
|
export {
|
||||||
useAddUserToWsE2EE,
|
useAddUserToWsE2EE,
|
||||||
useAddUserToWsNonE2EE,
|
useAddUserToWsNonE2EE,
|
||||||
|
useRemoveMyDuplicateAccounts,
|
||||||
useRevokeMySessionById,
|
useRevokeMySessionById,
|
||||||
useSendEmailVerificationCode,
|
useSendEmailVerificationCode,
|
||||||
useVerifyEmailVerificationCode
|
useVerifyEmailVerificationCode
|
||||||
@@ -14,6 +15,7 @@ export {
|
|||||||
useDeleteOrgMembership,
|
useDeleteOrgMembership,
|
||||||
useGetMyAPIKeys,
|
useGetMyAPIKeys,
|
||||||
useGetMyAPIKeysV2,
|
useGetMyAPIKeysV2,
|
||||||
|
useGetMyDuplicateAccount,
|
||||||
useGetMyIp,
|
useGetMyIp,
|
||||||
useGetMyOrganizationProjects,
|
useGetMyOrganizationProjects,
|
||||||
useGetMySessions,
|
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
|
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 = () => {
|
export const useDeleteMe = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const userKeys = {
|
export const userKeys = {
|
||||||
getUser: ["user"] as const,
|
getUser: ["user"] as const,
|
||||||
|
getMyDuplicateAccount: ["user-duplicate-account"] as const,
|
||||||
getPrivateKey: ["user"] as const,
|
getPrivateKey: ["user"] as const,
|
||||||
userAction: ["user-action"] as const,
|
userAction: ["user-action"] as const,
|
||||||
userProjectFavorites: (orgId: string) => [{ orgId }, "user-project-favorites"] as const,
|
userProjectFavorites: (orgId: string) => [{ orgId }, "user-project-favorites"] as const,
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import { useToggle } from "@app/hooks";
|
|||||||
import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api";
|
import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api";
|
||||||
import { MfaMethod } from "@app/hooks/api/auth/types";
|
import { MfaMethod } from "@app/hooks/api/auth/types";
|
||||||
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
|
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";
|
import { navigateUserToOrg, useNavigateToSelectOrganization } from "../../Login.utils";
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export const PasswordStep = ({
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [removeDuplicateLater, setRemoveDuplicateLater] = useState(true);
|
||||||
const { mutateAsync: selectOrganization } = useSelectOrganization();
|
const { mutateAsync: selectOrganization } = useSelectOrganization();
|
||||||
const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange();
|
const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange();
|
||||||
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
|
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
|
||||||
@@ -109,6 +111,13 @@ export const PasswordStep = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userDuplicateAccount = await fetchUserDuplicateAccounts();
|
||||||
|
const hasDuplicate = userDuplicateAccount?.length > 1;
|
||||||
|
if (hasDuplicate) {
|
||||||
|
setRemoveDuplicateLater(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await navigateUserToOrg(navigate, organizationId);
|
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) {
|
if (hasExchangedPrivateKey) {
|
||||||
return (
|
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">
|
<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 { 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 { Spinner } from "@app/components/v2";
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { useGetMyDuplicateAccount } from "@app/hooks/api";
|
||||||
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";
|
import { EmailDuplicationConfirmation } from "./EmailDuplicationConfirmation";
|
||||||
|
import { SelectOrganizationSection } from "./SelectOrgSection";
|
||||||
|
|
||||||
const LoadingScreen = () => {
|
const LoadingScreen = () => {
|
||||||
return (
|
return (
|
||||||
@@ -39,253 +16,18 @@ const LoadingScreen = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SelectOrganizationPage = () => {
|
export const SelectOrganizationPage = () => {
|
||||||
const navigate = useNavigate();
|
const duplicateAccounts = useGetMyDuplicateAccount();
|
||||||
const { t } = useTranslation();
|
const [removeDuplicateLater, setRemoveDuplicateLater] = useState(false);
|
||||||
|
|
||||||
const organizations = useGetOrganizations();
|
if (duplicateAccounts.isPending) {
|
||||||
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)
|
|
||||||
) {
|
|
||||||
return <LoadingScreen />;
|
return <LoadingScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (duplicateAccounts.data?.duplicateAccounts?.length && !removeDuplicateLater) {
|
||||||
<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">
|
return (
|
||||||
<Helmet>
|
<EmailDuplicationConfirmation onRemoveDuplicateLater={() => setRemoveDuplicateLater(true)} />
|
||||||
<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">
|
return <SelectOrganizationSection />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
289
frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx
Normal file
289
frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
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 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)
|
||||||
|
) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user