mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat: first login page base completed
This commit is contained in:
@@ -49,7 +49,7 @@ export default tseslint.config(
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"react-refresh/only-export-components": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
quotes: ["error", "double", { avoidEscape: true }],
|
||||
"comma-dangle": ["error", "only-multiline"],
|
||||
|
||||
1140
frontend-v2/package-lock.json
generated
1140
frontend-v2/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -72,6 +72,7 @@
|
||||
"react-code-input": "^3.10.1",
|
||||
"react-day-picker": "^9.4.3",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-hook-form": "^7.54.0",
|
||||
"react-i18next": "^15.2.0",
|
||||
"react-icons": "^5.4.0",
|
||||
@@ -101,6 +102,7 @@
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.1",
|
||||
@@ -119,6 +121,10 @@
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.15.0",
|
||||
"vite": "^6.0.1"
|
||||
"vite": "^6.0.1",
|
||||
"vite-plugin-node-polyfills": "^0.22.0",
|
||||
"vite-plugin-top-level-await": "^1.4.4",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { getAuthToken, setAuthToken, setMfaTempToken, setSignupTempToken } from "@app/reactQuery";
|
||||
import {
|
||||
getAuthToken,
|
||||
setAuthToken,
|
||||
setMfaTempToken,
|
||||
setSignupTempToken
|
||||
} from "@app/hooks/api/reactQuery";
|
||||
|
||||
export const PROVIDER_AUTH_TOKEN_KEY = "infisical__provider-auth-token";
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import argon2 from "argon2-browser";
|
||||
import argon2 from "argon2-browser/dist/argon2-bundled.min.js";
|
||||
import nacl from "tweetnacl";
|
||||
import { encodeBase64, decodeBase64, encodeUTF8, decodeUTF8 } from "tweetnacl-util";
|
||||
|
||||
import aes from "./aes-256-gcm";
|
||||
|
||||
const nacl = require("tweetnacl");
|
||||
nacl.util = require("tweetnacl-util");
|
||||
|
||||
/**
|
||||
* Return new base64, NaCl, public-private key pair.
|
||||
* @returns {Object} obj
|
||||
@@ -15,8 +14,8 @@ const generateKeyPair = () => {
|
||||
const pair = nacl.box.keyPair();
|
||||
|
||||
return {
|
||||
publicKey: nacl.util.encodeBase64(pair.publicKey),
|
||||
privateKey: nacl.util.encodeBase64(pair.secretKey)
|
||||
publicKey: encodeBase64(pair.publicKey),
|
||||
privateKey: encodeBase64(pair.secretKey)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -34,8 +33,8 @@ type EncryptAsymmetricProps = {
|
||||
* @param {String} - base64-encoded Nacl public key
|
||||
*/
|
||||
const verifyPrivateKey = ({ privateKey, publicKey }: { privateKey: string; publicKey: string }) => {
|
||||
const derivedPublicKey = nacl.util.encodeBase64(
|
||||
nacl.box.keyPair.fromSecretKey(nacl.util.decodeBase64(privateKey)).publicKey
|
||||
const derivedPublicKey = encodeBase64(
|
||||
nacl.box.keyPair.fromSecretKey(decodeBase64(privateKey)).publicKey
|
||||
);
|
||||
|
||||
if (derivedPublicKey !== publicKey) {
|
||||
@@ -108,15 +107,15 @@ const encryptAssymmetric = ({
|
||||
} => {
|
||||
const nonce = nacl.randomBytes(24);
|
||||
const ciphertext = nacl.box(
|
||||
nacl.util.decodeUTF8(plaintext),
|
||||
decodeUTF8(plaintext),
|
||||
nonce,
|
||||
nacl.util.decodeBase64(publicKey),
|
||||
nacl.util.decodeBase64(privateKey)
|
||||
decodeBase64(publicKey),
|
||||
decodeBase64(privateKey)
|
||||
);
|
||||
|
||||
return {
|
||||
ciphertext: nacl.util.encodeBase64(ciphertext),
|
||||
nonce: nacl.util.encodeBase64(nonce)
|
||||
ciphertext: encodeBase64(ciphertext),
|
||||
nonce: encodeBase64(nonce)
|
||||
};
|
||||
};
|
||||
|
||||
@@ -143,13 +142,13 @@ const decryptAssymmetric = ({
|
||||
privateKey
|
||||
}: DecryptAsymmetricProps): string => {
|
||||
const plaintext = nacl.box.open(
|
||||
nacl.util.decodeBase64(ciphertext),
|
||||
nacl.util.decodeBase64(nonce),
|
||||
nacl.util.decodeBase64(publicKey),
|
||||
nacl.util.decodeBase64(privateKey)
|
||||
decodeBase64(ciphertext),
|
||||
decodeBase64(nonce),
|
||||
decodeBase64(publicKey),
|
||||
decodeBase64(privateKey)
|
||||
);
|
||||
|
||||
return nacl.util.encodeUTF8(plaintext);
|
||||
return encodeUTF8(plaintext);
|
||||
};
|
||||
|
||||
type EncryptSymmetricProps = {
|
||||
|
||||
@@ -71,7 +71,7 @@ export const MenuItem = <T extends ElementType = "button">({
|
||||
lottieRef={iconRef}
|
||||
style={{ width: 22, height: 22 }}
|
||||
// eslint-disable-next-line import/no-dynamic-require
|
||||
animationData={require(`../../../../public/lotties/${icon}.json`)}
|
||||
// animationData={require(`../../../../public/lotties/${icon}.json`)}
|
||||
loop={false}
|
||||
autoplay={false}
|
||||
className="my-auto ml-[0.1rem] mr-3"
|
||||
@@ -121,7 +121,7 @@ export const SubMenuItem = <T extends ElementType = "button">({
|
||||
lottieRef={iconRef}
|
||||
style={{ width: 16, height: 16 }}
|
||||
// eslint-disable-next-line import/no-dynamic-require
|
||||
animationData={require(`../../../../public/lotties/${icon}.json`)}
|
||||
// animationData={require(`../../../../public/lotties/${icon}.json`)}
|
||||
loop={false}
|
||||
autoplay={false}
|
||||
className="my-auto ml-[0.1rem] mr-3"
|
||||
|
||||
@@ -3,8 +3,9 @@ import axios from "axios";
|
||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||
import { getAuthToken, getMfaTempToken, getSignupTempToken } from "@app/hooks/api/reactQuery";
|
||||
|
||||
// TODO(rbr): update this later
|
||||
export const apiRequest = axios.create({
|
||||
baseURL: "/",
|
||||
baseURL: "http://localhost:8080",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
33
frontend-v2/src/context/AuthContext/AuthContext.tsx
Normal file
33
frontend-v2/src/context/AuthContext/AuthContext.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { useGetAuthToken } from "@app/hooks/api";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
// TODO(akhilmhdh): Using react-simple-animate from hard dom offloading
|
||||
// smoother dom offloading needs to be done
|
||||
|
||||
// Authentication controller
|
||||
// Does route checking
|
||||
// Provide a context for whole app to notify user is authorized or not
|
||||
export const AuthProvider = ({ children }: Props): JSX.Element => {
|
||||
const { isLoading } = useGetAuthToken();
|
||||
|
||||
// wait for app to load the auth state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-bunker-800">
|
||||
<img
|
||||
src="/images/loading/loading.gif"
|
||||
height={70}
|
||||
width={120}
|
||||
alt="infisical loading indicator"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children as JSX.Element;
|
||||
};
|
||||
1
frontend-v2/src/context/AuthContext/index.tsx
Normal file
1
frontend-v2/src/context/AuthContext/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { AuthProvider } from "./AuthContext";
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createContext, ReactNode, useContext } from "react";
|
||||
|
||||
import { useGetUserOrgPermissions } from "@app/hooks/api";
|
||||
import { OrgUser } from "@app/hooks/api/types";
|
||||
|
||||
import { useOrganization } from "../OrganizationContext";
|
||||
import { TOrgPermission } from "./types";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const OrgPermissionContext = createContext<null | {
|
||||
permission: TOrgPermission;
|
||||
membership: OrgUser | null;
|
||||
}>(null);
|
||||
|
||||
export const OrgPermissionProvider = ({ children }: Props): JSX.Element => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const { data: permission, isLoading } = useGetUserOrgPermissions({ orgId });
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-bunker-800">
|
||||
<img
|
||||
src="/images/loading/loading.gif"
|
||||
height={70}
|
||||
width={120}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
alt="infisical loading indicator"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!permission) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-bunker-800">
|
||||
Failed to load user permissions
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<OrgPermissionContext.Provider value={permission}>{children}</OrgPermissionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useOrgPermission = () => {
|
||||
const ctx = useContext(OrgPermissionContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useOrgPermission to be used within <OrgPermissionProvider>");
|
||||
}
|
||||
|
||||
return ctx;
|
||||
};
|
||||
3
frontend-v2/src/context/OrgPermissionContext/index.tsx
Normal file
3
frontend-v2/src/context/OrgPermissionContext/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export { OrgPermissionProvider, useOrgPermission } from "./OrgPermissionContext";
|
||||
export type { TOrgPermission } from "./types";
|
||||
export { OrgPermissionActions, OrgPermissionSubjects } from "./types";
|
||||
52
frontend-v2/src/context/OrgPermissionContext/types.ts
Normal file
52
frontend-v2/src/context/OrgPermissionContext/types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { MongoAbility } from "@casl/ability";
|
||||
|
||||
export enum OrgPermissionActions {
|
||||
Read = "read",
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
Delete = "delete"
|
||||
}
|
||||
|
||||
export enum OrgPermissionSubjects {
|
||||
Workspace = "workspace",
|
||||
Role = "role",
|
||||
Member = "member",
|
||||
Settings = "settings",
|
||||
IncidentAccount = "incident-contact",
|
||||
Scim = "scim",
|
||||
Sso = "sso",
|
||||
Ldap = "ldap",
|
||||
Groups = "groups",
|
||||
Billing = "billing",
|
||||
SecretScanning = "secret-scanning",
|
||||
Identity = "identity",
|
||||
Kms = "kms",
|
||||
AdminConsole = "organization-admin-console",
|
||||
AuditLogs = "audit-logs",
|
||||
ProjectTemplates = "project-templates"
|
||||
}
|
||||
|
||||
export enum OrgPermissionAdminConsoleAction {
|
||||
AccessAllProjects = "access-all-projects"
|
||||
}
|
||||
|
||||
export type OrgPermissionSet =
|
||||
| [OrgPermissionActions.Create, OrgPermissionSubjects.Workspace]
|
||||
| [OrgPermissionActions.Read, OrgPermissionSubjects.Workspace]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Role]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Member]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Settings]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Scim]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Ldap]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Groups]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Identity]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
|
||||
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates];
|
||||
|
||||
export type TOrgPermission = MongoAbility<OrgPermissionSet>;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createContext, ReactNode, useContext, useMemo } from "react";
|
||||
|
||||
import { useGetOrganizations } from "@app/hooks/api";
|
||||
import { Organization } from "@app/hooks/api/types";
|
||||
|
||||
type TOrgContext = {
|
||||
orgs?: Organization[];
|
||||
currentOrg?: Organization;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const OrgContext = createContext<TOrgContext | null>(null);
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const OrgProvider = ({ children }: Props): JSX.Element => {
|
||||
const { data: userOrgs, isLoading } = useGetOrganizations();
|
||||
|
||||
// const currentWsOrgID = currentWorkspace?.organization;
|
||||
const currentWsOrgID = localStorage.getItem("orgData.id");
|
||||
|
||||
// memorize the workspace details for the context
|
||||
const value = useMemo<TOrgContext>(
|
||||
() => ({
|
||||
orgs: userOrgs,
|
||||
currentOrg: (userOrgs || []).find(({ id }) => id === currentWsOrgID),
|
||||
isLoading
|
||||
}),
|
||||
[currentWsOrgID, userOrgs, isLoading]
|
||||
);
|
||||
|
||||
return <OrgContext.Provider value={value}>{children}</OrgContext.Provider>;
|
||||
};
|
||||
|
||||
export const useOrganization = () => {
|
||||
const ctx = useContext(OrgContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useOrganization to be used within <OrgContext.Provider>");
|
||||
}
|
||||
|
||||
return ctx;
|
||||
};
|
||||
1
frontend-v2/src/context/OrganizationContext/index.tsx
Normal file
1
frontend-v2/src/context/OrganizationContext/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { OrgProvider, useOrganization } from "./OrganizationContext";
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createContext, ReactNode, useContext } from "react";
|
||||
|
||||
import { useGetUserProjectPermissions } from "@app/hooks/api";
|
||||
import { TProjectMembership } from "@app/hooks/api/users/types";
|
||||
|
||||
import { useWorkspace } from "../WorkspaceContext";
|
||||
import { TProjectPermission } from "./types";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const ProjectPermissionContext = createContext<null | {
|
||||
permission: TProjectPermission;
|
||||
membership: TProjectMembership;
|
||||
}>(null);
|
||||
|
||||
export const ProjectPermissionProvider = ({ children }: Props): JSX.Element => {
|
||||
const { currentWorkspace, isLoading: isWsLoading } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const { data: permission, isLoading } = useGetUserProjectPermissions({ workspaceId });
|
||||
|
||||
if ((isLoading && currentWorkspace) || isWsLoading) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-bunker-800">
|
||||
<img
|
||||
src="/images/loading/loading.gif"
|
||||
height={70}
|
||||
width={120}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
alt="infisical loading indicator"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!permission && currentWorkspace) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-bunker-800">
|
||||
Failed to load user permissions
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectPermissionContext.Provider value={permission!}>
|
||||
{children}
|
||||
</ProjectPermissionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useProjectPermission = () => {
|
||||
const ctx = useContext(ProjectPermissionContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useProjectPermission to be used within <ProjectPermissionContext>");
|
||||
}
|
||||
|
||||
const hasProjectRole = (role: string) => ctx?.membership?.roles?.includes(role) || false;
|
||||
|
||||
return { ...ctx, hasProjectRole };
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export { ProjectPermissionProvider, useProjectPermission } from "./ProjectPermissionContext";
|
||||
export type { ProjectPermissionSet, TProjectPermission } from "./types";
|
||||
export {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionCmekActions,
|
||||
ProjectPermissionDynamicSecretActions,
|
||||
ProjectPermissionSub
|
||||
} from "./types";
|
||||
176
frontend-v2/src/context/ProjectPermissionContext/types.ts
Normal file
176
frontend-v2/src/context/ProjectPermissionContext/types.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { ForcedSubject, MongoAbility } from "@casl/ability";
|
||||
|
||||
export enum ProjectPermissionActions {
|
||||
Read = "read",
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
Delete = "delete"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionDynamicSecretActions {
|
||||
ReadRootCredential = "read-root-credential",
|
||||
CreateRootCredential = "create-root-credential",
|
||||
EditRootCredential = "edit-root-credential",
|
||||
DeleteRootCredential = "delete-root-credential",
|
||||
Lease = "lease"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionCmekActions {
|
||||
Read = "read",
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
Delete = "delete",
|
||||
Encrypt = "encrypt",
|
||||
Decrypt = "decrypt"
|
||||
}
|
||||
|
||||
export enum PermissionConditionOperators {
|
||||
$IN = "$in",
|
||||
$ALL = "$all",
|
||||
$REGEX = "$regex",
|
||||
$EQ = "$eq",
|
||||
$NEQ = "$ne",
|
||||
$GLOB = "$glob"
|
||||
}
|
||||
|
||||
export type IdentityManagementSubjectFields = {
|
||||
identityId: string;
|
||||
};
|
||||
|
||||
export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = {
|
||||
[PermissionConditionOperators.$EQ]: "equal to",
|
||||
[PermissionConditionOperators.$IN]: "contains",
|
||||
[PermissionConditionOperators.$ALL]: "contains all",
|
||||
[PermissionConditionOperators.$NEQ]: "not equal to",
|
||||
[PermissionConditionOperators.$GLOB]: "matches glob pattern",
|
||||
[PermissionConditionOperators.$REGEX]: "matches regex pattern"
|
||||
};
|
||||
|
||||
export type TPermissionConditionOperators = {
|
||||
[PermissionConditionOperators.$IN]: string[];
|
||||
[PermissionConditionOperators.$ALL]: string[];
|
||||
[PermissionConditionOperators.$EQ]: string;
|
||||
[PermissionConditionOperators.$NEQ]: string;
|
||||
[PermissionConditionOperators.$REGEX]: string;
|
||||
[PermissionConditionOperators.$GLOB]: string;
|
||||
};
|
||||
|
||||
export type TPermissionCondition = Record<
|
||||
string,
|
||||
| string
|
||||
| { $in: string[]; $all: string[]; $regex: string; $eq: string; $ne: string; $glob: string }
|
||||
>;
|
||||
|
||||
export enum ProjectPermissionSub {
|
||||
Role = "role",
|
||||
Member = "member",
|
||||
Groups = "groups",
|
||||
Settings = "settings",
|
||||
Integrations = "integrations",
|
||||
Webhooks = "webhooks",
|
||||
ServiceTokens = "service-tokens",
|
||||
Environments = "environments",
|
||||
Tags = "tags",
|
||||
AuditLogs = "audit-logs",
|
||||
IpAllowList = "ip-allowlist",
|
||||
Project = "workspace",
|
||||
Secrets = "secrets",
|
||||
SecretFolders = "secret-folders",
|
||||
SecretImports = "secret-imports",
|
||||
DynamicSecrets = "dynamic-secrets",
|
||||
SecretRollback = "secret-rollback",
|
||||
SecretApproval = "secret-approval",
|
||||
SecretRotation = "secret-rotation",
|
||||
Identity = "identity",
|
||||
CertificateAuthorities = "certificate-authorities",
|
||||
Certificates = "certificates",
|
||||
CertificateTemplates = "certificate-templates",
|
||||
PkiAlerts = "pki-alerts",
|
||||
PkiCollections = "pki-collections",
|
||||
Kms = "kms",
|
||||
Cmek = "cmek"
|
||||
}
|
||||
|
||||
export type SecretSubjectFields = {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secretName: string;
|
||||
secretTags: string[];
|
||||
};
|
||||
|
||||
export type SecretFolderSubjectFields = {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
};
|
||||
|
||||
export type DynamicSecretSubjectFields = {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
};
|
||||
|
||||
export type SecretImportSubjectFields = {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
};
|
||||
|
||||
export type ProjectPermissionSet =
|
||||
| [
|
||||
ProjectPermissionActions,
|
||||
(
|
||||
| ProjectPermissionSub.Secrets
|
||||
| (ForcedSubject<ProjectPermissionSub.Secrets> & SecretSubjectFields)
|
||||
)
|
||||
]
|
||||
| [
|
||||
ProjectPermissionActions,
|
||||
(
|
||||
| ProjectPermissionSub.SecretFolders
|
||||
| (ForcedSubject<ProjectPermissionSub.SecretFolders> & SecretFolderSubjectFields)
|
||||
)
|
||||
]
|
||||
| [
|
||||
ProjectPermissionDynamicSecretActions,
|
||||
(
|
||||
| ProjectPermissionSub.DynamicSecrets
|
||||
| (ForcedSubject<ProjectPermissionSub.DynamicSecrets> & DynamicSecretSubjectFields)
|
||||
)
|
||||
]
|
||||
| [
|
||||
ProjectPermissionActions,
|
||||
(
|
||||
| ProjectPermissionSub.SecretImports
|
||||
| (ForcedSubject<ProjectPermissionSub.SecretImports> & SecretImportSubjectFields)
|
||||
)
|
||||
]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Role]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Tags]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Member]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Groups]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Integrations]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Webhooks]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.AuditLogs]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Environments]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.IpAllowList]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Settings]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.SecretRotation]
|
||||
| [
|
||||
ProjectPermissionActions,
|
||||
(
|
||||
| ProjectPermissionSub.Identity
|
||||
| (ForcedSubject<ProjectPermissionSub.Identity> & IdentityManagementSubjectFields)
|
||||
)
|
||||
]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Certificates]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
|
||||
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Project]
|
||||
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Project]
|
||||
| [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback]
|
||||
| [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback]
|
||||
| [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek]
|
||||
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Kms];
|
||||
export type TProjectPermission = MongoAbility<ProjectPermissionSet>;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createContext, ReactNode, useContext, useEffect, useMemo } from "react";
|
||||
|
||||
import { ContentLoader } from "@app/components/v2/ContentLoader";
|
||||
import { useGetServerConfig } from "@app/hooks/api";
|
||||
import { TServerConfig } from "@app/hooks/api/admin/types";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
type TServerConfigContext = {
|
||||
config: TServerConfig;
|
||||
};
|
||||
|
||||
const ServerConfigContext = createContext<TServerConfigContext | null>(null);
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const ServerConfigProvider = ({ children }: Props): JSX.Element => {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useGetServerConfig();
|
||||
|
||||
// memorize the workspace details for the context
|
||||
const value = useMemo<TServerConfigContext>(() => {
|
||||
return {
|
||||
config: data!
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && data && !data.initialized && !data.isMigrationModeOn) {
|
||||
navigate({ to: "/admin/signup" });
|
||||
}
|
||||
}, [isLoading, data]);
|
||||
|
||||
if (!isLoading && data?.isMigrationModeOn) {
|
||||
return (
|
||||
<div className="relative mx-auto flex h-screen w-full flex-col items-center justify-center space-y-8 bg-bunker-800 px-8 text-mineshaft-50 dark:[color-scheme:dark]">
|
||||
<Helmet>
|
||||
<title>Infisical Maintenance Mode</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Helmet>
|
||||
<img
|
||||
src="/images/maintenance.png"
|
||||
height={175}
|
||||
width={300}
|
||||
alt="maintenance mode"
|
||||
className="w-[40rem]"
|
||||
/>
|
||||
<p className="mx-8 mb-4 flex justify-center bg-gradient-to-tr from-mineshaft-300 to-white bg-clip-text text-4xl font-bold text-transparent md:mx-16">
|
||||
Scheduled Maintenance
|
||||
</p>
|
||||
<div className="mt-2 text-center text-lg text-bunker-300">
|
||||
Infisical is undergoing planned maintenance. <br /> No action is required on your end —
|
||||
your applications will continue to fetch secrets.
|
||||
<br /> If you have questions, please{" "}
|
||||
<a
|
||||
className="text-bunker-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/slack"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
join our Slack community
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-bunker-800">
|
||||
<ContentLoader text="Loading configurations" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ServerConfigContext.Provider value={value}>{children}</ServerConfigContext.Provider>;
|
||||
};
|
||||
|
||||
export const useServerConfig = () => {
|
||||
const ctx = useContext(ServerConfigContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useServerConfig has to be used within <UserContext.Provider>");
|
||||
}
|
||||
|
||||
return ctx;
|
||||
};
|
||||
1
frontend-v2/src/context/ServerConfigContext/index.tsx
Normal file
1
frontend-v2/src/context/ServerConfigContext/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { ServerConfigProvider, useServerConfig } from "./ServerConfigContext";
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createContext, ReactNode, useContext, useMemo } from "react";
|
||||
|
||||
import { useGetOrgSubscription } from "@app/hooks/api";
|
||||
import { SubscriptionPlan } from "@app/hooks/api/types";
|
||||
|
||||
import { useOrganization } from "../OrganizationContext";
|
||||
|
||||
type TSubscriptionContext = {
|
||||
subscription?: SubscriptionPlan;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const SubscriptionContext = createContext<TSubscriptionContext | null>(null);
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const SubscriptionProvider = ({ children }: Props): JSX.Element => {
|
||||
const { currentOrg } = useOrganization();
|
||||
|
||||
const { data, isLoading } = useGetOrgSubscription({
|
||||
orgID: currentOrg?.id || ""
|
||||
});
|
||||
|
||||
// memorize the workspace details for the context
|
||||
const value = useMemo<TSubscriptionContext>(
|
||||
() => ({
|
||||
subscription: data,
|
||||
isLoading
|
||||
}),
|
||||
[data, isLoading]
|
||||
);
|
||||
|
||||
return <SubscriptionContext.Provider value={value}>{children}</SubscriptionContext.Provider>;
|
||||
};
|
||||
|
||||
export const useSubscription = () => {
|
||||
const ctx = useContext(SubscriptionContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useSubscription has to be used within <SubscriptionContext.Provider>");
|
||||
}
|
||||
|
||||
return ctx;
|
||||
};
|
||||
1
frontend-v2/src/context/SubscriptionContext/index.tsx
Normal file
1
frontend-v2/src/context/SubscriptionContext/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { SubscriptionProvider, useSubscription } from "./SubscriptionContext";
|
||||
53
frontend-v2/src/context/UserContext/UserContext.tsx
Normal file
53
frontend-v2/src/context/UserContext/UserContext.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createContext, ReactNode, useContext, useMemo } from "react";
|
||||
|
||||
import { useGetUser } from "@app/hooks/api";
|
||||
import { User, UserEnc } from "@app/hooks/api/types";
|
||||
|
||||
type TUserContext = {
|
||||
user: User & UserEnc;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const UserContext = createContext<TUserContext | null>(null);
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const UserProvider = ({ children }: Props): JSX.Element => {
|
||||
const { data, isLoading } = useGetUser();
|
||||
|
||||
// memorize the workspace details for the context
|
||||
const value = useMemo<TUserContext>(() => {
|
||||
return {
|
||||
user: data!,
|
||||
isLoading
|
||||
};
|
||||
}, [data, isLoading]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-bunker-800">
|
||||
<img
|
||||
src="/images/loading/loading.gif"
|
||||
height={70}
|
||||
width={120}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
alt="infisical loading indicator"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
|
||||
};
|
||||
|
||||
export const useUser = () => {
|
||||
const ctx = useContext(UserContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useUser has to be used within <UserContext.Provider>");
|
||||
}
|
||||
|
||||
return ctx;
|
||||
};
|
||||
1
frontend-v2/src/context/UserContext/index.tsx
Normal file
1
frontend-v2/src/context/UserContext/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { UserProvider, useUser } from "./UserContext";
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createContext, ReactNode, useContext, useEffect, useMemo } from "react";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { useGetUserWorkspaces } from "@app/hooks/api";
|
||||
import { Workspace } from "@app/hooks/api/workspace/types";
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
|
||||
type TWorkspaceContext = {
|
||||
workspaces: Workspace[];
|
||||
currentWorkspace?: Workspace;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
const WorkspaceContext = createContext<TWorkspaceContext | null>(null);
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const WorkspaceProvider = ({ children }: Props): JSX.Element => {
|
||||
const { data: ws, isLoading } = useGetUserWorkspaces();
|
||||
const params = useParams({ strict: false });
|
||||
const workspaceId = params.id;
|
||||
|
||||
// memorize the workspace details for the context
|
||||
const value = useMemo<TWorkspaceContext>(() => {
|
||||
const wsId = workspaceId || localStorage.getItem("projectData.id");
|
||||
return {
|
||||
workspaces: ws || [],
|
||||
currentWorkspace: (ws || []).find(({ id }) => id === wsId),
|
||||
isLoading
|
||||
};
|
||||
}, [ws, workspaceId, isLoading]);
|
||||
|
||||
const shouldTriggerNoProjectAccess = !value.isLoading && !value.currentWorkspace;
|
||||
|
||||
if (shouldTriggerNoProjectAccess) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-bunker-800 text-primary-50">
|
||||
You do not have sufficient access to this project.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <WorkspaceContext.Provider value={value}>{children}</WorkspaceContext.Provider>;
|
||||
};
|
||||
|
||||
export const useWorkspace = () => {
|
||||
const ctx = useContext(WorkspaceContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useWorkspace has to be used within <WorkspaceContext.Provider>");
|
||||
}
|
||||
|
||||
return ctx;
|
||||
};
|
||||
1
frontend-v2/src/context/WorkspaceContext/index.tsx
Normal file
1
frontend-v2/src/context/WorkspaceContext/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { useWorkspace, WorkspaceProvider } from "./WorkspaceContext";
|
||||
22
frontend-v2/src/context/index.tsx
Normal file
22
frontend-v2/src/context/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export { AuthProvider } from "./AuthContext";
|
||||
export { OrgProvider, useOrganization } from "./OrganizationContext";
|
||||
export type { TOrgPermission } from "./OrgPermissionContext";
|
||||
export {
|
||||
OrgPermissionActions,
|
||||
OrgPermissionProvider,
|
||||
OrgPermissionSubjects,
|
||||
useOrgPermission
|
||||
} from "./OrgPermissionContext";
|
||||
export type { TProjectPermission } from "./ProjectPermissionContext";
|
||||
export {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionCmekActions,
|
||||
ProjectPermissionDynamicSecretActions,
|
||||
ProjectPermissionProvider,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission
|
||||
} from "./ProjectPermissionContext";
|
||||
export { ServerConfigProvider, useServerConfig } from "./ServerConfigContext";
|
||||
export { SubscriptionProvider, useSubscription } from "./SubscriptionContext";
|
||||
export { UserProvider, useUser } from "./UserContext";
|
||||
export { useWorkspace, WorkspaceProvider } from "./WorkspaceContext";
|
||||
84
frontend-v2/src/helpers/key.ts
Normal file
84
frontend-v2/src/helpers/key.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
|
||||
import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto";
|
||||
|
||||
/**
|
||||
* @param {Object} obj
|
||||
* @param {Number} obj.encryptionVersion
|
||||
* @param {String} obj.encryptedPrivateKey
|
||||
* @param {String} obj.iv
|
||||
* @param {String} obj.tag
|
||||
* @param {String} obj.password
|
||||
* @param {String} obj.salt
|
||||
* @param {String} obj.protectedKey
|
||||
* @param {String} obj.protectedKeyIV
|
||||
* @param {String} obj.protectedKeyTag
|
||||
*/
|
||||
const decryptPrivateKeyHelper = async ({
|
||||
encryptionVersion,
|
||||
encryptedPrivateKey,
|
||||
iv,
|
||||
tag,
|
||||
password,
|
||||
salt,
|
||||
protectedKey,
|
||||
protectedKeyIV,
|
||||
protectedKeyTag
|
||||
}: {
|
||||
encryptionVersion: number;
|
||||
encryptedPrivateKey: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
password: string;
|
||||
salt: string;
|
||||
protectedKey?: string;
|
||||
protectedKeyIV?: string;
|
||||
protectedKeyTag?: string;
|
||||
}) => {
|
||||
let privateKey;
|
||||
try {
|
||||
if (encryptionVersion === 1) {
|
||||
privateKey = Aes256Gcm.decrypt({
|
||||
ciphertext: encryptedPrivateKey,
|
||||
iv,
|
||||
tag,
|
||||
secret: password
|
||||
.slice(0, 32)
|
||||
.padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), "0")
|
||||
});
|
||||
} else if (encryptionVersion === 2 && protectedKey && protectedKeyIV && protectedKeyTag) {
|
||||
const derivedKey = await deriveArgonKey({
|
||||
password,
|
||||
salt,
|
||||
mem: 65536,
|
||||
time: 3,
|
||||
parallelism: 1,
|
||||
hashLen: 32
|
||||
});
|
||||
|
||||
if (!derivedKey) throw new Error("Failed to generate derived key");
|
||||
|
||||
const key = Aes256Gcm.decrypt({
|
||||
ciphertext: protectedKey,
|
||||
iv: protectedKeyIV,
|
||||
tag: protectedKeyTag,
|
||||
secret: Buffer.from(derivedKey.hash)
|
||||
});
|
||||
|
||||
// decrypt back the private key
|
||||
privateKey = Aes256Gcm.decrypt({
|
||||
ciphertext: encryptedPrivateKey,
|
||||
iv,
|
||||
tag,
|
||||
secret: Buffer.from(key, "hex")
|
||||
});
|
||||
} else {
|
||||
throw new Error("Insufficient details to decrypt private key");
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error("Failed to decrypt private key");
|
||||
}
|
||||
|
||||
return privateKey;
|
||||
};
|
||||
|
||||
export { decryptPrivateKeyHelper };
|
||||
12
frontend-v2/src/helpers/members.ts
Normal file
12
frontend-v2/src/helpers/members.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { TWorkspaceUser } from "@app/hooks/api/users/types";
|
||||
|
||||
export const getMemberLabel = (member: TWorkspaceUser) => {
|
||||
const {
|
||||
inviteEmail,
|
||||
user: { firstName, lastName, username, email }
|
||||
} = member;
|
||||
|
||||
return firstName || lastName
|
||||
? `${firstName ?? ""} ${lastName ?? ""}`.trim()
|
||||
: username || email || inviteEmail;
|
||||
};
|
||||
31
frontend-v2/src/helpers/parseEnvVar.ts
Normal file
31
frontend-v2/src/helpers/parseEnvVar.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/** Extracts the key and value from a passed in env string based on the provided delimiters. */
|
||||
export const getKeyValue = (pastedContent: string, delimiters: string[]) => {
|
||||
if (!pastedContent) {
|
||||
return { key: "", value: "" };
|
||||
}
|
||||
|
||||
let firstDelimiterIndex = -1;
|
||||
let foundDelimiter = "";
|
||||
|
||||
delimiters.forEach((delimiter) => {
|
||||
const index = pastedContent.indexOf(delimiter);
|
||||
if (index !== -1 && (firstDelimiterIndex === -1 || index < firstDelimiterIndex)) {
|
||||
firstDelimiterIndex = index;
|
||||
foundDelimiter = delimiter;
|
||||
}
|
||||
});
|
||||
|
||||
const hasValueAfterDelimiter = pastedContent.length > firstDelimiterIndex + foundDelimiter.length;
|
||||
|
||||
if (firstDelimiterIndex === -1 || !hasValueAfterDelimiter) {
|
||||
return { key: pastedContent.trim(), value: "" };
|
||||
}
|
||||
|
||||
const key = pastedContent.substring(0, firstDelimiterIndex);
|
||||
const value = pastedContent.substring(firstDelimiterIndex + foundDelimiter.length);
|
||||
|
||||
return {
|
||||
key: key.trim(),
|
||||
value: value.trim()
|
||||
};
|
||||
};
|
||||
12
frontend-v2/src/helpers/policies.ts
Normal file
12
frontend-v2/src/helpers/policies.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { PolicyType } from "@app/hooks/api/policies/enums";
|
||||
|
||||
export const policyDetails: Record<PolicyType, { name: string; className: string }> = {
|
||||
[PolicyType.AccessPolicy]: {
|
||||
className: "bg-lime-900 text-lime-100",
|
||||
name: "Access Policy"
|
||||
},
|
||||
[PolicyType.ChangePolicy]: {
|
||||
className: "bg-indigo-900 text-indigo-100",
|
||||
name: "Change Policy"
|
||||
}
|
||||
};
|
||||
62
frontend-v2/src/helpers/project.ts
Normal file
62
frontend-v2/src/helpers/project.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { apiRequest } from "@app/config/request";
|
||||
import { createWorkspace } from "@app/hooks/api/workspace/queries";
|
||||
|
||||
const secretsToBeAdded = [
|
||||
{
|
||||
secretKey: "DATABASE_URL",
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
secretValue: "mongodb+srv://${DB_USERNAME}:${DB_PASSWORD}@mongodb.net",
|
||||
secretComment: "Secret referencing example"
|
||||
},
|
||||
{
|
||||
secretKey: "DB_USERNAME",
|
||||
secretValue: "OVERRIDE_THIS",
|
||||
secretComment: "Override secrets with personal value"
|
||||
},
|
||||
{
|
||||
secretKey: "DB_PASSWORD",
|
||||
secretValue: "OVERRIDE_THIS",
|
||||
secretComment: "Another secret override"
|
||||
},
|
||||
{
|
||||
secretKey: "DB_PASSWORD",
|
||||
secretValue: "example_password"
|
||||
},
|
||||
{
|
||||
secretKey: "TWILIO_AUTH_TOKEN",
|
||||
secretValue: "example_twillio_token"
|
||||
},
|
||||
{
|
||||
secretKey: "WEBSITE_URL",
|
||||
secretValue: "http://localhost:3000"
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Create and initialize a new project in organization with id [organizationId]
|
||||
* Note: current user should be a member of the organization
|
||||
*/
|
||||
const initProjectHelper = async ({ projectName }: { projectName: string }) => {
|
||||
// create new project
|
||||
const {
|
||||
data: { project }
|
||||
} = await createWorkspace({
|
||||
projectName
|
||||
});
|
||||
|
||||
try {
|
||||
const { data } = await apiRequest.post("/api/v3/secrets/batch/raw", {
|
||||
workspaceId: project.id,
|
||||
environment: "dev",
|
||||
secretPath: "/",
|
||||
secrets: secretsToBeAdded
|
||||
});
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error("Failed to upload secrets", err);
|
||||
}
|
||||
|
||||
return project;
|
||||
};
|
||||
|
||||
export { initProjectHelper };
|
||||
5
frontend-v2/src/helpers/reverseTruncate.ts
Normal file
5
frontend-v2/src/helpers/reverseTruncate.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const reverseTruncate = (text: string, maxLength = 42) => {
|
||||
if (text.length < maxLength) return text;
|
||||
|
||||
return `...${text.substring(text.length - maxLength + 3)}`;
|
||||
};
|
||||
30
frontend-v2/src/helpers/roles.ts
Normal file
30
frontend-v2/src/helpers/roles.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ProjectMembershipRole, TOrgRole } from "@app/hooks/api/roles/types";
|
||||
|
||||
enum OrgMembershipRole {
|
||||
Admin = "admin",
|
||||
Member = "member",
|
||||
NoAccess = "no-access"
|
||||
}
|
||||
|
||||
enum ProjectMemberRole {
|
||||
Admin = "admin",
|
||||
Member = "member",
|
||||
Viewer = "viewer",
|
||||
NoAccess = "no-access"
|
||||
}
|
||||
|
||||
export const isCustomOrgRole = (slug: string) =>
|
||||
!Object.values(OrgMembershipRole).includes(slug as OrgMembershipRole);
|
||||
|
||||
export const formatProjectRoleName = (name: string) => {
|
||||
if (name === ProjectMemberRole.Member) return "developer";
|
||||
return name;
|
||||
};
|
||||
|
||||
export const isCustomProjectRole = (slug: string) =>
|
||||
!Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole);
|
||||
|
||||
export const findOrgMembershipRole = (roles: TOrgRole[], roleIdOrSlug: string) =>
|
||||
isCustomOrgRole(roleIdOrSlug)
|
||||
? roles.find((r) => r.id === roleIdOrSlug)
|
||||
: roles.find((r) => r.slug === roleIdOrSlug);
|
||||
15
frontend-v2/src/helpers/string.ts
Normal file
15
frontend-v2/src/helpers/string.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const removeTrailingSlash = (str: string) => {
|
||||
if (str === "/") return str;
|
||||
|
||||
return str.endsWith("/") ? str.slice(0, -1) : str;
|
||||
};
|
||||
|
||||
export const isValidPath = (val: string): boolean => {
|
||||
if (val.length === 0) return false;
|
||||
if (val === "/") return true;
|
||||
|
||||
// Check for valid characters and no consecutive slashes
|
||||
const validPathRegex = /^[a-zA-Z0-9-_.:]+(?:\/[a-zA-Z0-9-_.:]+)*$/;
|
||||
return validPathRegex.test(val);
|
||||
};
|
||||
|
||||
@@ -2,16 +2,16 @@ import { MutationCache, QueryClient } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
|
||||
// akhilmhdh: doing individual imports to avoid cyclic import error
|
||||
import { Button } from "./components/v2/Button";
|
||||
import { Modal, ModalContent, ModalTrigger } from "./components/v2/Modal";
|
||||
import { Table, TableContainer, TBody, Td, Th, THead, Tr } from "./components/v2/Table";
|
||||
import { Button } from "@app/components/v2/Button";
|
||||
import { Modal, ModalContent, ModalTrigger } from "@app/components/v2/Modal";
|
||||
import { Table, TableContainer, TBody, Td, Th, THead, Tr } from "@app/components/v2/Table";
|
||||
import {
|
||||
formatedConditionsOperatorNames,
|
||||
PermissionConditionOperators
|
||||
} from "./context/ProjectPermissionContext/types";
|
||||
import { ApiErrorTypes, TApiErrors } from "./hooks/api/types";
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
|
||||
import { ApiErrorTypes, TApiErrors } from "./types";
|
||||
|
||||
// this is saved in react-query cache
|
||||
export const SIGNUP_TEMP_TOKEN_CACHE_KEY = ["infisical__signup-temp-token"];
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as IndexImport } from './routes/index'
|
||||
import { Route as LoginIndexImport } from './routes/login/index'
|
||||
import { Route as LoginSsoIndexImport } from './routes/login/sso/index'
|
||||
import { Route as LoginSelectOrganizationIndexImport } from './routes/login/select-organization/index'
|
||||
import { Route as LoginLdapIndexImport } from './routes/login/ldap/index'
|
||||
import { Route as LoginProviderSuccessImport } from './routes/login/provider/success'
|
||||
import { Route as LoginProviderErrorImport } from './routes/login/provider/error'
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
@@ -21,6 +27,43 @@ const IndexRoute = IndexImport.update({
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const LoginIndexRoute = LoginIndexImport.update({
|
||||
id: '/login/',
|
||||
path: '/login/',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const LoginSsoIndexRoute = LoginSsoIndexImport.update({
|
||||
id: '/login/sso/',
|
||||
path: '/login/sso/',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const LoginSelectOrganizationIndexRoute =
|
||||
LoginSelectOrganizationIndexImport.update({
|
||||
id: '/login/select-organization/',
|
||||
path: '/login/select-organization/',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const LoginLdapIndexRoute = LoginLdapIndexImport.update({
|
||||
id: '/login/ldap/',
|
||||
path: '/login/ldap/',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const LoginProviderSuccessRoute = LoginProviderSuccessImport.update({
|
||||
id: '/login/provider/success',
|
||||
path: '/login/provider/success',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const LoginProviderErrorRoute = LoginProviderErrorImport.update({
|
||||
id: '/login/provider/error',
|
||||
path: '/login/provider/error',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
// Populate the FileRoutesByPath interface
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -32,6 +75,48 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/login/': {
|
||||
id: '/login/'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof LoginIndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/login/provider/error': {
|
||||
id: '/login/provider/error'
|
||||
path: '/login/provider/error'
|
||||
fullPath: '/login/provider/error'
|
||||
preLoaderRoute: typeof LoginProviderErrorImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/login/provider/success': {
|
||||
id: '/login/provider/success'
|
||||
path: '/login/provider/success'
|
||||
fullPath: '/login/provider/success'
|
||||
preLoaderRoute: typeof LoginProviderSuccessImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/login/ldap/': {
|
||||
id: '/login/ldap/'
|
||||
path: '/login/ldap'
|
||||
fullPath: '/login/ldap'
|
||||
preLoaderRoute: typeof LoginLdapIndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/login/select-organization/': {
|
||||
id: '/login/select-organization/'
|
||||
path: '/login/select-organization'
|
||||
fullPath: '/login/select-organization'
|
||||
preLoaderRoute: typeof LoginSelectOrganizationIndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/login/sso/': {
|
||||
id: '/login/sso/'
|
||||
path: '/login/sso'
|
||||
fullPath: '/login/sso'
|
||||
preLoaderRoute: typeof LoginSsoIndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,32 +124,84 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginIndexRoute
|
||||
'/login/provider/error': typeof LoginProviderErrorRoute
|
||||
'/login/provider/success': typeof LoginProviderSuccessRoute
|
||||
'/login/ldap': typeof LoginLdapIndexRoute
|
||||
'/login/select-organization': typeof LoginSelectOrganizationIndexRoute
|
||||
'/login/sso': typeof LoginSsoIndexRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginIndexRoute
|
||||
'/login/provider/error': typeof LoginProviderErrorRoute
|
||||
'/login/provider/success': typeof LoginProviderSuccessRoute
|
||||
'/login/ldap': typeof LoginLdapIndexRoute
|
||||
'/login/select-organization': typeof LoginSelectOrganizationIndexRoute
|
||||
'/login/sso': typeof LoginSsoIndexRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRoute
|
||||
'/': typeof IndexRoute
|
||||
'/login/': typeof LoginIndexRoute
|
||||
'/login/provider/error': typeof LoginProviderErrorRoute
|
||||
'/login/provider/success': typeof LoginProviderSuccessRoute
|
||||
'/login/ldap/': typeof LoginLdapIndexRoute
|
||||
'/login/select-organization/': typeof LoginSelectOrganizationIndexRoute
|
||||
'/login/sso/': typeof LoginSsoIndexRoute
|
||||
}
|
||||
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/login/provider/error'
|
||||
| '/login/provider/success'
|
||||
| '/login/ldap'
|
||||
| '/login/select-organization'
|
||||
| '/login/sso'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/'
|
||||
id: '__root__' | '/'
|
||||
to:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/login/provider/error'
|
||||
| '/login/provider/success'
|
||||
| '/login/ldap'
|
||||
| '/login/select-organization'
|
||||
| '/login/sso'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/login/'
|
||||
| '/login/provider/error'
|
||||
| '/login/provider/success'
|
||||
| '/login/ldap/'
|
||||
| '/login/select-organization/'
|
||||
| '/login/sso/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
LoginIndexRoute: typeof LoginIndexRoute
|
||||
LoginProviderErrorRoute: typeof LoginProviderErrorRoute
|
||||
LoginProviderSuccessRoute: typeof LoginProviderSuccessRoute
|
||||
LoginLdapIndexRoute: typeof LoginLdapIndexRoute
|
||||
LoginSelectOrganizationIndexRoute: typeof LoginSelectOrganizationIndexRoute
|
||||
LoginSsoIndexRoute: typeof LoginSsoIndexRoute
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
LoginIndexRoute: LoginIndexRoute,
|
||||
LoginProviderErrorRoute: LoginProviderErrorRoute,
|
||||
LoginProviderSuccessRoute: LoginProviderSuccessRoute,
|
||||
LoginLdapIndexRoute: LoginLdapIndexRoute,
|
||||
LoginSelectOrganizationIndexRoute: LoginSelectOrganizationIndexRoute,
|
||||
LoginSsoIndexRoute: LoginSsoIndexRoute,
|
||||
}
|
||||
|
||||
export const routeTree = rootRoute
|
||||
@@ -77,11 +214,35 @@ export const routeTree = rootRoute
|
||||
"__root__": {
|
||||
"filePath": "__root.tsx",
|
||||
"children": [
|
||||
"/"
|
||||
"/",
|
||||
"/login/",
|
||||
"/login/provider/error",
|
||||
"/login/provider/success",
|
||||
"/login/ldap/",
|
||||
"/login/select-organization/",
|
||||
"/login/sso/"
|
||||
]
|
||||
},
|
||||
"/": {
|
||||
"filePath": "index.tsx"
|
||||
},
|
||||
"/login/": {
|
||||
"filePath": "login/index.tsx"
|
||||
},
|
||||
"/login/provider/error": {
|
||||
"filePath": "login/provider/error.tsx"
|
||||
},
|
||||
"/login/provider/success": {
|
||||
"filePath": "login/provider/success.tsx"
|
||||
},
|
||||
"/login/ldap/": {
|
||||
"filePath": "login/ldap/index.tsx"
|
||||
},
|
||||
"/login/select-organization/": {
|
||||
"filePath": "login/select-organization/index.tsx"
|
||||
},
|
||||
"/login/sso/": {
|
||||
"filePath": "login/sso/index.tsx"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { createRootRoute, Link, Outlet } from "@tanstack/react-router";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
|
||||
|
||||
import { queryClient } from "@app/hooks/api/reactQuery";
|
||||
import { ServerConfigProvider } from "@app/context";
|
||||
import { TooltipProvider } from "@app/components/v2";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => (
|
||||
<>
|
||||
<div className="flex gap-2 p-2">
|
||||
<Link to="/" className="[&.active]:font-bold">
|
||||
Home
|
||||
</Link>
|
||||
</div>
|
||||
<hr />
|
||||
<Outlet />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<ServerConfigProvider>
|
||||
<Outlet />
|
||||
</ServerConfigProvider>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
<TanStackRouterDevtools />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { faGithub, faGitlab, faGoogle } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faLock } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
||||
|
||||
import Error from "@app/components/basic/Error";
|
||||
import { RegionSelect } from "@app/components/navigation/RegionSelect";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import attemptCliLogin from "@app/components/utilities/attemptCliLogin";
|
||||
import attemptLogin from "@app/components/utilities/attemptLogin";
|
||||
import { CAPTCHA_SITE_KEY } from "@app/components/utilities/config";
|
||||
import { Button, IconButton, Input, Tooltip } from "@app/components/v2";
|
||||
import { useServerConfig } from "@app/context";
|
||||
import { useFetchServerStatus } from "@app/hooks/api";
|
||||
import { LoginMethod } from "@app/hooks/api/admin/types";
|
||||
import { AuthMethod } from "@app/hooks/api/users/types";
|
||||
|
||||
import { useNavigateToSelectOrganization } from "../Login.utils";
|
||||
|
||||
type Props = {
|
||||
setStep: (step: number) => void;
|
||||
email: string;
|
||||
setEmail: (email: string) => void;
|
||||
password: string;
|
||||
setPassword: (email: string) => void;
|
||||
};
|
||||
|
||||
export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loginError, setLoginError] = useState(false);
|
||||
const { config } = useServerConfig();
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
const [captchaToken, setCaptchaToken] = useState("");
|
||||
const [shouldShowCaptcha, setShouldShowCaptcha] = useState(false);
|
||||
const captchaRef = useRef<HCaptcha>(null);
|
||||
const { data: serverDetails } = useFetchServerStatus();
|
||||
|
||||
const { navigateToSelectOrganization } = useNavigateToSelectOrganization();
|
||||
|
||||
const redirectToSaml = (orgSlug: string) => {
|
||||
const callbackPort = queryParams.get("callback_port");
|
||||
const redirectUrl = `/api/v1/sso/redirect/saml2/organizations/${orgSlug}${
|
||||
callbackPort ? `?callback_port=${callbackPort}` : ""
|
||||
}`;
|
||||
navigate({ to: redirectUrl });
|
||||
};
|
||||
|
||||
const redirectToOidc = (orgSlug: string) => {
|
||||
const callbackPort = queryParams.get("callback_port");
|
||||
const redirectUrl = `/api/v1/sso/oidc/login?orgSlug=${orgSlug}${
|
||||
callbackPort ? `&callbackPort=${callbackPort}` : ""
|
||||
}`;
|
||||
navigate({ to: redirectUrl });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (serverDetails?.samlDefaultOrgSlug) redirectToSaml(serverDetails.samlDefaultOrgSlug);
|
||||
}, [serverDetails?.samlDefaultOrgSlug]);
|
||||
|
||||
const handleSaml = () => {
|
||||
if (config.defaultAuthOrgSlug) {
|
||||
redirectToSaml(config.defaultAuthOrgSlug);
|
||||
} else {
|
||||
setStep(2);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOidc = () => {
|
||||
if (config.defaultAuthOrgSlug) {
|
||||
redirectToOidc(config.defaultAuthOrgSlug);
|
||||
} else {
|
||||
setStep(3);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldDisplayLoginMethod = (method: LoginMethod) =>
|
||||
!config.enabledLoginMethods || config.enabledLoginMethods.includes(method);
|
||||
|
||||
const handleLogin = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (!email || !password) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
if (queryParams && queryParams.get("callback_port")) {
|
||||
const callbackPort = queryParams.get("callback_port");
|
||||
|
||||
// attemptCliLogin
|
||||
const isCliLoginSuccessful = await attemptCliLogin({
|
||||
email: email.toLowerCase(),
|
||||
password,
|
||||
captchaToken
|
||||
});
|
||||
|
||||
if (isCliLoginSuccessful && isCliLoginSuccessful.success) {
|
||||
navigateToSelectOrganization(callbackPort!);
|
||||
} else {
|
||||
setLoginError(true);
|
||||
createNotification({
|
||||
text: "CLI login unsuccessful. Double-check your credentials and try again.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const isLoginSuccessful = await attemptLogin({
|
||||
email: email.toLowerCase(),
|
||||
password,
|
||||
captchaToken
|
||||
});
|
||||
|
||||
if (isLoginSuccessful && isLoginSuccessful.success) {
|
||||
// case: login was successful
|
||||
navigateToSelectOrganization();
|
||||
createNotification({
|
||||
text: "Successfully logged in",
|
||||
type: "success"
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
if (err.response.data.error === "User Locked") {
|
||||
createNotification({
|
||||
title: err.response.data.error,
|
||||
text: err.response.data.message,
|
||||
type: "error"
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (err.response.data.error === "Captcha Required") {
|
||||
setShouldShowCaptcha(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoginError(true);
|
||||
createNotification({
|
||||
text: "Login unsuccessful. Double-check your credentials and try again.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
|
||||
if (captchaRef.current) {
|
||||
captchaRef.current.resetCaptcha();
|
||||
}
|
||||
|
||||
setCaptchaToken("");
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
if (config.defaultAuthOrgAuthEnforced && config.defaultAuthOrgAuthMethod) {
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleLogin}
|
||||
className="mx-auto flex w-full flex-col items-center justify-center"
|
||||
>
|
||||
<h1 className="mb-8 bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
|
||||
Login to Infisical
|
||||
</h1>
|
||||
<RegionSelect />
|
||||
{config.defaultAuthOrgAuthMethod === AuthMethod.SAML && (
|
||||
<div className="w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
onClick={handleSaml}
|
||||
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
|
||||
className="mx-0 h-10 w-full"
|
||||
>
|
||||
Continue with SAML
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{config.defaultAuthOrgAuthMethod === AuthMethod.OIDC && (
|
||||
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
onClick={handleOidc}
|
||||
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
|
||||
className="mx-0 h-10 w-full"
|
||||
>
|
||||
Continue with OIDC
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleLogin}
|
||||
className="mx-auto flex w-full flex-col items-center justify-center"
|
||||
>
|
||||
<h1 className="mb-8 bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
|
||||
Login to Infisical
|
||||
</h1>
|
||||
<RegionSelect />
|
||||
{shouldDisplayLoginMethod(LoginMethod.SAML) && (
|
||||
<div className="w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
onClick={handleSaml}
|
||||
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
|
||||
className="mx-0 h-10 w-full"
|
||||
>
|
||||
Continue with SAML
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{shouldDisplayLoginMethod(LoginMethod.OIDC) && (
|
||||
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
onClick={handleOidc}
|
||||
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
|
||||
className="mx-0 h-10 w-full"
|
||||
>
|
||||
Continue with OIDC
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{shouldDisplayLoginMethod(LoginMethod.LDAP) && (
|
||||
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
navigate({ to: "/login/ldap" });
|
||||
}}
|
||||
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
|
||||
className="mx-0 h-10 w-full"
|
||||
>
|
||||
Continue with LDAP
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex w-1/4 min-w-[21.2rem] gap-2 md:min-w-[20.1rem] lg:w-1/6">
|
||||
{shouldDisplayLoginMethod(LoginMethod.GOOGLE) && (
|
||||
<Tooltip position="bottom" content={t("login.continue-with-google")}>
|
||||
<IconButton
|
||||
ariaLabel={t("login.continue-with-google")}
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
const callbackPort = queryParams.get("callback_port");
|
||||
|
||||
window.open(
|
||||
`/api/v1/sso/redirect/google${
|
||||
callbackPort ? `?callback_port=${callbackPort}` : ""
|
||||
}`
|
||||
);
|
||||
window.close();
|
||||
}}
|
||||
className="h-10 w-full bg-mineshaft-600"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGoogle} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{shouldDisplayLoginMethod(LoginMethod.GITHUB) && (
|
||||
<Tooltip position="bottom" content="Continue with GitHub">
|
||||
<IconButton
|
||||
ariaLabel="Login continue with GitHub"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
const callbackPort = queryParams.get("callback_port");
|
||||
|
||||
window.open(
|
||||
`/api/v1/sso/redirect/github${
|
||||
callbackPort ? `?callback_port=${callbackPort}` : ""
|
||||
}`
|
||||
);
|
||||
|
||||
window.close();
|
||||
}}
|
||||
className="h-10 w-full bg-mineshaft-600"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGithub} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{shouldDisplayLoginMethod(LoginMethod.GITLAB) && (
|
||||
<Tooltip position="bottom" content="Continue with GitLab">
|
||||
<IconButton
|
||||
ariaLabel="Login continue with GitLab"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
const callbackPort = queryParams.get("callback_port");
|
||||
|
||||
window.open(
|
||||
`/api/v1/sso/redirect/gitlab${
|
||||
callbackPort ? `?callback_port=${callbackPort}` : ""
|
||||
}`
|
||||
);
|
||||
|
||||
window.close();
|
||||
}}
|
||||
className="h-10 w-full bg-mineshaft-600"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGitlab} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{(!config.enabledLoginMethods ||
|
||||
(shouldDisplayLoginMethod(LoginMethod.EMAIL) && config.enabledLoginMethods.length > 1)) && (
|
||||
<div className="my-4 flex w-1/4 min-w-[20rem] flex-row items-center py-2 lg:w-1/6">
|
||||
<div className="w-full border-t border-mineshaft-400/60" />
|
||||
<span className="mx-2 text-xs text-mineshaft-200">or</span>
|
||||
<div className="w-full border-t border-mineshaft-400/60" />
|
||||
</div>
|
||||
)}
|
||||
{shouldDisplayLoginMethod(LoginMethod.EMAIL) && (
|
||||
<>
|
||||
<div className="w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
|
||||
<Input
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
type="email"
|
||||
placeholder="Enter your email..."
|
||||
isRequired
|
||||
autoComplete="username"
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
|
||||
<Input
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
type="password"
|
||||
placeholder="Enter your password..."
|
||||
isRequired
|
||||
autoComplete="current-password"
|
||||
id="current-password"
|
||||
className="select:-webkit-autofill:focus h-10"
|
||||
/>
|
||||
</div>
|
||||
{shouldShowCaptcha && (
|
||||
<div className="mt-4">
|
||||
<HCaptcha
|
||||
theme="dark"
|
||||
sitekey={CAPTCHA_SITE_KEY}
|
||||
onVerify={(token) => setCaptchaToken(token)}
|
||||
ref={captchaRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
|
||||
<Button
|
||||
disabled={shouldShowCaptcha && captchaToken === ""}
|
||||
type="submit"
|
||||
size="sm"
|
||||
isFullWidth
|
||||
className="h-10"
|
||||
colorSchema="primary"
|
||||
variant="solid"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{" "}
|
||||
Continue with Email{" "}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isLoading && loginError && <Error text={t("login.error-login") ?? ""} />}
|
||||
{config.allowSignUp &&
|
||||
(shouldDisplayLoginMethod(LoginMethod.EMAIL) ||
|
||||
shouldDisplayLoginMethod(LoginMethod.GOOGLE) ||
|
||||
shouldDisplayLoginMethod(LoginMethod.GITHUB) ||
|
||||
shouldDisplayLoginMethod(LoginMethod.GITLAB)) ? (
|
||||
<div className="mt-6 flex flex-row text-sm text-bunker-400">
|
||||
<Link href="/signup">
|
||||
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
|
||||
Don't have an account yet? {t("login.create-account")}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4" />
|
||||
)}
|
||||
{shouldDisplayLoginMethod(LoginMethod.EMAIL) && (
|
||||
<div className="mt-2 flex flex-row text-sm text-bunker-400">
|
||||
<Link href="/verify-email">
|
||||
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
|
||||
Forgot password? Recover your account
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { InitialStep } from "./InitialStep";
|
||||
51
frontend-v2/src/routes/login/-components/Login.utils.tsx
Normal file
51
frontend-v2/src/routes/login/-components/Login.utils.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NavigateFn, useNavigate } from "@tanstack/react-router";
|
||||
|
||||
import { useServerConfig } from "@app/context";
|
||||
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
|
||||
import { queryClient } from "@app/hooks/api/reactQuery";
|
||||
import { userKeys } from "@app/hooks/api/users";
|
||||
|
||||
export const navigateUserToOrg = async (navigate: NavigateFn, organizationId?: string) => {
|
||||
const userOrgs = await fetchOrganizations();
|
||||
|
||||
const nonAuthEnforcedOrgs = userOrgs.filter((org) => !org.authEnforced);
|
||||
|
||||
if (organizationId) {
|
||||
localStorage.setItem("orgData.id", organizationId);
|
||||
navigate({ to: `/org/${organizationId}/overview` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (nonAuthEnforcedOrgs.length > 0) {
|
||||
// user is part of at least 1 non-auth enforced org
|
||||
const userOrg = nonAuthEnforcedOrgs[0] && nonAuthEnforcedOrgs[0].id;
|
||||
localStorage.setItem("orgData.id", userOrg);
|
||||
navigate({ to: `/org/${userOrg}/overview` });
|
||||
} else {
|
||||
// user is not part of any non-auth enforced orgs
|
||||
localStorage.removeItem("orgData.id");
|
||||
navigate({ to: "/org/none" });
|
||||
}
|
||||
};
|
||||
|
||||
export const useNavigateToSelectOrganization = () => {
|
||||
const { config } = useServerConfig();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const navigateToSelectOrganization = async (cliCallbackPort?: string) => {
|
||||
let redirectTo = "/login/select-organization?";
|
||||
if (config.defaultAuthOrgId) {
|
||||
redirectTo += `org_id=${config.defaultAuthOrgId}&`;
|
||||
} else {
|
||||
queryClient.invalidateQueries(userKeys.getUser);
|
||||
}
|
||||
|
||||
if (cliCallbackPort) {
|
||||
redirectTo += `callback_port=${cliCallbackPort}`;
|
||||
}
|
||||
|
||||
navigate({ to: redirectTo });
|
||||
};
|
||||
|
||||
return { navigateToSelectOrganization };
|
||||
};
|
||||
41
frontend-v2/src/routes/login/-components/LoginSSO.tsx
Normal file
41
frontend-v2/src/routes/login/-components/LoginSSO.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
|
||||
import { PasswordStep } from "./PasswordStep";
|
||||
|
||||
type Props = {
|
||||
providerAuthToken: string;
|
||||
};
|
||||
|
||||
export const LoginSSO = ({ providerAuthToken }: Props) => {
|
||||
const [step, setStep] = useState(0);
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const { username, isUserCompleted } = jwtDecode(providerAuthToken) as any;
|
||||
|
||||
useEffect(() => {
|
||||
if (isUserCompleted) {
|
||||
setStep(1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderView = () => {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return <div />;
|
||||
case 1:
|
||||
return (
|
||||
<PasswordStep
|
||||
providerAuthToken={providerAuthToken}
|
||||
email={username}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <div />;
|
||||
}
|
||||
};
|
||||
|
||||
return <div>{renderView()}</div>;
|
||||
};
|
||||
222
frontend-v2/src/routes/login/-components/Mfa.tsx
Normal file
222
frontend-v2/src/routes/login/-components/Mfa.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import ReactCodeInput from "react-code-input";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { t } from "i18next";
|
||||
|
||||
import Error from "@app/components/basic/Error";
|
||||
import TotpRegistration from "@app/components/mfa/TotpRegistration";
|
||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||
import { Button, Input } from "@app/components/v2";
|
||||
import { useSendMfaToken } from "@app/hooks/api";
|
||||
import { checkUserTotpMfa, verifyMfaToken } from "@app/hooks/api/auth/queries";
|
||||
import { MfaMethod } from "@app/hooks/api/auth/types";
|
||||
|
||||
// The style for the verification code input
|
||||
const codeInputProps = {
|
||||
inputStyle: {
|
||||
fontFamily: "monospace",
|
||||
margin: "4px",
|
||||
MozAppearance: "textfield",
|
||||
width: "48px",
|
||||
borderRadius: "5px",
|
||||
fontSize: "24px",
|
||||
height: "48px",
|
||||
paddingLeft: "7",
|
||||
backgroundColor: "#0d1117",
|
||||
color: "white",
|
||||
border: "1px solid #2d2f33",
|
||||
textAlign: "center",
|
||||
outlineColor: "#8ca542",
|
||||
borderColor: "#2d2f33"
|
||||
}
|
||||
} as const;
|
||||
|
||||
type Props = {
|
||||
successCallback: () => void | Promise<void>;
|
||||
closeMfa?: () => void;
|
||||
hideLogo?: boolean;
|
||||
email: string;
|
||||
method: MfaMethod;
|
||||
};
|
||||
|
||||
export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Props) => {
|
||||
const [mfaCode, setMfaCode] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingResend, setIsLoadingResend] = useState(false);
|
||||
const [triesLeft, setTriesLeft] = useState<number | undefined>(undefined);
|
||||
const [shouldShowTotpRegistration, setShouldShowTotpRegistration] = useState(false);
|
||||
|
||||
const sendMfaToken = useSendMfaToken();
|
||||
|
||||
useEffect(() => {
|
||||
if (method === MfaMethod.TOTP) {
|
||||
checkUserTotpMfa().then((isVerified) => {
|
||||
if (!isVerified) {
|
||||
SecurityClient.setMfaToken("");
|
||||
setShouldShowTotpRegistration(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const verifyMfa = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { token } = await verifyMfaToken({
|
||||
email,
|
||||
mfaCode,
|
||||
mfaMethod: method
|
||||
});
|
||||
|
||||
SecurityClient.setMfaToken("");
|
||||
SecurityClient.setToken(token);
|
||||
|
||||
await successCallback();
|
||||
if (closeMfa) {
|
||||
closeMfa();
|
||||
}
|
||||
} catch {
|
||||
if (triesLeft) {
|
||||
setTriesLeft((left) => {
|
||||
if (triesLeft === 1) {
|
||||
navigate({ to: "/" });
|
||||
|
||||
SecurityClient.setMfaToken("");
|
||||
SecurityClient.setToken("");
|
||||
}
|
||||
return (left as number) - 1;
|
||||
});
|
||||
} else {
|
||||
setTriesLeft(2);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendMfaCode = async () => {
|
||||
try {
|
||||
setIsLoadingResend(true);
|
||||
await sendMfaToken.mutateAsync({ email });
|
||||
setIsLoadingResend(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setIsLoadingResend(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (shouldShowTotpRegistration) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 text-center text-lg font-bold text-white">
|
||||
Your organization requires mobile authentication to be configured.
|
||||
</div>
|
||||
<div className="mx-auto w-max pb-4 pt-4 md:mb-16 md:px-8">
|
||||
<TotpRegistration
|
||||
shouldCenterQr
|
||||
onComplete={async () => {
|
||||
setShouldShowTotpRegistration(false);
|
||||
await successCallback();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-max pb-4 pt-4 md:mb-16 md:px-8">
|
||||
{!hideLogo && (
|
||||
<Link href="/">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
{method === MfaMethod.EMAIL && (
|
||||
<>
|
||||
<p className="text-l flex justify-center text-bunker-300">{t("mfa.step2-message")}</p>
|
||||
<p className="text-l my-1 flex justify-center font-semibold text-bunker-300">{email}</p>
|
||||
</>
|
||||
)}
|
||||
{method === MfaMethod.TOTP && (
|
||||
<>
|
||||
<p className="text-l mb-4 flex max-w-xs justify-center text-center font-bold text-bunker-100">
|
||||
Authenticator MFA Required
|
||||
</p>
|
||||
<p className="text-l flex max-w-xs justify-center text-center text-bunker-300">
|
||||
Open the authenticator app on your mobile device to get your verification code or enter
|
||||
a recovery code.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<form onSubmit={verifyMfa}>
|
||||
<div className="mx-auto hidden w-max min-w-[20rem] md:block">
|
||||
{method === MfaMethod.EMAIL && (
|
||||
<ReactCodeInput
|
||||
name=""
|
||||
inputMode="tel"
|
||||
type="text"
|
||||
fields={6}
|
||||
onChange={setMfaCode}
|
||||
className="mb-2 mt-6"
|
||||
{...codeInputProps}
|
||||
/>
|
||||
)}
|
||||
{method === MfaMethod.TOTP && (
|
||||
<div className="mb-4 mt-6">
|
||||
<Input value={mfaCode} onChange={(e) => setMfaCode(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{typeof triesLeft === "number" && (
|
||||
<Error text={`Invalid code. You have ${triesLeft} attempt(s) remaining.`} />
|
||||
)}
|
||||
<div className="mx-auto mt-2 flex w-1/4 min-w-[20rem] max-w-xs flex-col items-center justify-center text-center text-sm md:max-w-md md:text-left lg:w-[19%]">
|
||||
<div className="text-l w-full py-1 text-lg">
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
isFullWidth
|
||||
className="h-14"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{String(t("mfa.verify"))}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{method === MfaMethod.TOTP && (
|
||||
<div className="mt-2 flex flex-row justify-center text-sm text-bunker-400">
|
||||
<Link href="/verify-email">
|
||||
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
|
||||
Lost your recovery codes? Reset your account
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{method === MfaMethod.EMAIL && (
|
||||
<div className="mx-auto flex max-h-24 w-full max-w-md flex-col items-center justify-center pt-2">
|
||||
<div className="flex flex-row items-baseline gap-1 text-sm">
|
||||
<span className="text-bunker-400">{t("signup.step2-resend-alert")}</span>
|
||||
<div className="text-md mt-2 flex flex-row text-bunker-400">
|
||||
<button disabled={isLoadingResend} onClick={handleResendMfaCode} type="button">
|
||||
<span className="cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
|
||||
{isLoadingResend
|
||||
? t("signup.step2-resend-progress")
|
||||
: t("signup.step2-resend-submit")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="pb-2 text-sm text-bunker-400">{t("signup.step2-spam-alert")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,379 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
||||
import axios from "axios";
|
||||
import { addSeconds, formatISO } from "date-fns";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import attemptCliLogin from "@app/components/utilities/attemptCliLogin";
|
||||
import attemptLogin from "@app/components/utilities/attemptLogin";
|
||||
import { CAPTCHA_SITE_KEY } from "@app/components/utilities/config";
|
||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||
import { Button, Input, Spinner } from "@app/components/v2";
|
||||
import { SessionStorageKeys } from "@app/const";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api";
|
||||
import { MfaMethod } from "@app/hooks/api/auth/types";
|
||||
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
|
||||
import { fetchMyPrivateKey } from "@app/hooks/api/users/queries";
|
||||
|
||||
import { navigateUserToOrg, useNavigateToSelectOrganization } from "../Login.utils";
|
||||
import { Mfa } from "../Mfa";
|
||||
|
||||
type Props = {
|
||||
providerAuthToken: string;
|
||||
email: string;
|
||||
password: string;
|
||||
setPassword: (password: string) => void;
|
||||
};
|
||||
|
||||
export const PasswordStep = ({ providerAuthToken, email, password, setPassword }: Props) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { mutateAsync: selectOrganization } = useSelectOrganization();
|
||||
const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange();
|
||||
const [shouldShowMfa, toggleShowMfa] = useToggle(false);
|
||||
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
|
||||
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
|
||||
|
||||
const { navigateToSelectOrganization } = useNavigateToSelectOrganization();
|
||||
|
||||
const { callbackPort, organizationId, hasExchangedPrivateKey } = jwtDecode(
|
||||
providerAuthToken
|
||||
) as any;
|
||||
|
||||
const handleExchange = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const oauthLogin = await oauthTokenExchange({
|
||||
email,
|
||||
providerAuthToken
|
||||
});
|
||||
|
||||
// attemptCliLogin
|
||||
const cliUrl = `http://127.0.0.1:${callbackPort}/`;
|
||||
|
||||
// unset provider auth token in case it was used
|
||||
SecurityClient.setProviderAuthToken("");
|
||||
// set JWT token
|
||||
SecurityClient.setToken(oauthLogin.token);
|
||||
|
||||
const privateKey = await fetchMyPrivateKey();
|
||||
localStorage.setItem("PRIVATE_KEY", privateKey);
|
||||
|
||||
// case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org
|
||||
if (organizationId) {
|
||||
const finishWithOrgWorkflow = async () => {
|
||||
const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ organizationId });
|
||||
|
||||
if (isMfaEnabled) {
|
||||
SecurityClient.setMfaToken(token);
|
||||
setMfaSuccessCallback(() => finishWithOrgWorkflow);
|
||||
if (mfaMethod) {
|
||||
setRequiredMfaMethod(mfaMethod);
|
||||
}
|
||||
toggleShowMfa.on();
|
||||
return;
|
||||
}
|
||||
|
||||
if (callbackPort) {
|
||||
console.log("organization id was present. new JWT token to be used in CLI:", token);
|
||||
const instance = axios.create();
|
||||
const payload = {
|
||||
privateKey,
|
||||
email,
|
||||
JTWToken: token
|
||||
};
|
||||
await instance.post(cliUrl, 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" });
|
||||
return;
|
||||
}
|
||||
|
||||
await navigateUserToOrg(navigate, organizationId);
|
||||
};
|
||||
|
||||
await finishWithOrgWorkflow();
|
||||
}
|
||||
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs
|
||||
// if the user has no orgs, navigate to the create org page
|
||||
else {
|
||||
const userOrgs = await fetchOrganizations();
|
||||
|
||||
// case: user has orgs, so we navigate the user to select an org
|
||||
if (userOrgs.length > 0) {
|
||||
navigateToSelectOrganization(callbackPort);
|
||||
}
|
||||
// case: no orgs found, so we navigate the user to create an org
|
||||
else {
|
||||
await navigateUserToOrg(navigate);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
setIsLoading(false);
|
||||
console.error(err);
|
||||
|
||||
if (err.response.data.error === "User Locked") {
|
||||
createNotification({
|
||||
title: err.response.data.error,
|
||||
text: err.response.data.message,
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createNotification({
|
||||
text: "Login unsuccessful. Double-check your master password and try again.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExchangedPrivateKey) {
|
||||
handleExchange();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [captchaToken, setCaptchaToken] = useState("");
|
||||
const [shouldShowCaptcha, setShouldShowCaptcha] = useState(false);
|
||||
const captchaRef = useRef<HCaptcha>(null);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
if (callbackPort) {
|
||||
// attemptCliLogin
|
||||
const isCliLoginSuccessful = await attemptCliLogin({
|
||||
email,
|
||||
password,
|
||||
providerAuthToken,
|
||||
captchaToken
|
||||
});
|
||||
|
||||
if (isCliLoginSuccessful && isCliLoginSuccessful.success) {
|
||||
const cliUrl = `http://127.0.0.1:${callbackPort}/`;
|
||||
|
||||
// case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org
|
||||
if (organizationId) {
|
||||
const finishWithOrgWorkflow = async () => {
|
||||
const { token, isMfaEnabled, mfaMethod } = await selectOrganization({
|
||||
organizationId
|
||||
});
|
||||
|
||||
if (isMfaEnabled) {
|
||||
SecurityClient.setMfaToken(token);
|
||||
if (mfaMethod) {
|
||||
setRequiredMfaMethod(mfaMethod);
|
||||
}
|
||||
toggleShowMfa.on();
|
||||
setMfaSuccessCallback(() => finishWithOrgWorkflow);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("organization id was present. new JWT token to be used in CLI:", token);
|
||||
|
||||
const instance = axios.create();
|
||||
const payload = {
|
||||
...isCliLoginSuccessful.loginResponse,
|
||||
JTWToken: token
|
||||
};
|
||||
await instance.post(cliUrl, 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" });
|
||||
};
|
||||
|
||||
await finishWithOrgWorkflow();
|
||||
return;
|
||||
}
|
||||
|
||||
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs
|
||||
// if the user has no orgs, navigate to the create org page
|
||||
const userOrgs = await fetchOrganizations();
|
||||
|
||||
// case: user has orgs, so we navigate the user to select an org
|
||||
if (userOrgs.length > 0) {
|
||||
navigateToSelectOrganization(callbackPort);
|
||||
}
|
||||
// case: no orgs found, so we navigate the user to create an org
|
||||
else {
|
||||
await navigateUserToOrg(navigate);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const loginAttempt = await attemptLogin({
|
||||
email,
|
||||
password,
|
||||
providerAuthToken,
|
||||
captchaToken
|
||||
});
|
||||
|
||||
if (loginAttempt && loginAttempt.success) {
|
||||
// case: login was successful
|
||||
setIsLoading(false);
|
||||
createNotification({
|
||||
text: "Successfully logged in",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
// case: organization ID is present from the provider auth token -- navigate directly to the org
|
||||
if (organizationId) {
|
||||
await navigateUserToOrg(navigate, organizationId);
|
||||
}
|
||||
// case: no organization ID is present -- navigate to the select org page IF the user has any orgs
|
||||
// if the user has no orgs, navigate to the create org page
|
||||
else {
|
||||
const userOrgs = await fetchOrganizations();
|
||||
|
||||
if (userOrgs.length > 0) {
|
||||
navigateToSelectOrganization();
|
||||
} else {
|
||||
await navigateUserToOrg(navigate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
setIsLoading(false);
|
||||
console.error(err);
|
||||
|
||||
if (err.response.data.error === "User Locked") {
|
||||
createNotification({
|
||||
title: err.response.data.error,
|
||||
text: err.response.data.message,
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (err.response.data.error === "Captcha Required") {
|
||||
setShouldShowCaptcha(true);
|
||||
return;
|
||||
}
|
||||
|
||||
createNotification({
|
||||
text: "Login unsuccessful. Double-check your master password and try again.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
|
||||
if (captchaRef.current) {
|
||||
captchaRef.current.resetCaptcha();
|
||||
}
|
||||
setCaptchaToken("");
|
||||
};
|
||||
|
||||
if (shouldShowMfa) {
|
||||
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">
|
||||
<Mfa
|
||||
email={email}
|
||||
successCallback={mfaSuccessCallback}
|
||||
method={requiredMfaMethod}
|
||||
closeMfa={() => toggleShowMfa.off()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasExchangedPrivateKey) {
|
||||
return (
|
||||
<div className="flex max-h-screen min-h-screen flex-col items-center justify-center gap-2 overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
|
||||
<Spinner />
|
||||
<p className="text-white opacity-80">Loading, please wait</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleLogin} className="mx-auto h-full w-full max-w-md px-6 pt-8">
|
||||
<div className="mb-8">
|
||||
<p className="mx-auto mb-4 flex w-max justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
|
||||
What's your Infisical password?
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative mx-auto flex max-h-24 w-1/4 w-full min-w-[22rem] items-center justify-center rounded-lg md:max-h-28 lg:w-1/6">
|
||||
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
|
||||
<Input
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
type="password"
|
||||
placeholder="Enter your password..."
|
||||
isRequired
|
||||
autoComplete="current-password"
|
||||
id="current-password"
|
||||
className="h-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{shouldShowCaptcha && (
|
||||
<div className="mx-auto mt-4 flex w-full min-w-[22rem] items-center justify-center lg:w-1/6">
|
||||
<HCaptcha
|
||||
theme="dark"
|
||||
sitekey={CAPTCHA_SITE_KEY}
|
||||
onVerify={(token) => setCaptchaToken(token)}
|
||||
ref={captchaRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mx-auto mt-4 flex w-1/4 w-full min-w-[22rem] items-center justify-center rounded-md text-center lg:w-1/6">
|
||||
<Button
|
||||
disabled={shouldShowCaptcha && captchaToken === ""}
|
||||
type="submit"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isFullWidth
|
||||
isLoading={isLoading}
|
||||
className="h-14"
|
||||
>
|
||||
{t("login.login")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mx-auto mt-4 flex w-max flex-col items-center text-xs text-bunker-400">
|
||||
<span className="max-w-sm px-4 text-center duration-200">
|
||||
Infisical Master Password serves as a decryption mechanism so that even Google is not able
|
||||
to access your secrets.
|
||||
</span>
|
||||
<Link href="/verify-email">
|
||||
<span className="mt-2 cursor-pointer duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4">
|
||||
{t("login.forgot-password")}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate({ to: "/login" });
|
||||
}}
|
||||
type="button"
|
||||
className="mt-2 cursor-pointer text-xs text-bunker-400 duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"
|
||||
>
|
||||
{t("login.other-option")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { PasswordStep } from "./PasswordStep";
|
||||
82
frontend-v2/src/routes/login/-components/SSOStep/SSOStep.tsx
Normal file
82
frontend-v2/src/routes/login/-components/SSOStep/SSOStep.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button, Input } from "@app/components/v2";
|
||||
|
||||
type Props = {
|
||||
setStep: (step: number) => void;
|
||||
type: "SAML" | "OIDC";
|
||||
};
|
||||
|
||||
export const SSOStep = ({ setStep, type }: Props) => {
|
||||
const [ssoIdentifier, setSSOIdentifier] = useState("");
|
||||
const { t } = useTranslation();
|
||||
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
|
||||
const handleSubmission = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const callbackPort = queryParams.get("callback_port");
|
||||
if (type === "SAML") {
|
||||
window.open(
|
||||
`/api/v1/sso/redirect/saml2/organizations/${ssoIdentifier}${
|
||||
callbackPort ? `?callback_port=${callbackPort}` : ""
|
||||
}`
|
||||
);
|
||||
} else {
|
||||
window.open(
|
||||
`/api/v1/sso/oidc/login?orgSlug=${ssoIdentifier}${
|
||||
callbackPort ? `&callbackPort=${callbackPort}` : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
window.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md md:px-6">
|
||||
<p className="mx-auto mb-8 flex w-max justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
|
||||
What's your organization slug?
|
||||
</p>
|
||||
<form onSubmit={handleSubmission}>
|
||||
<div className="relative mx-auto flex max-h-24 w-full min-w-[20rem] items-center justify-center rounded-lg md:max-h-28 md:min-w-[22rem] lg:w-1/6">
|
||||
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
|
||||
<Input
|
||||
value={ssoIdentifier}
|
||||
onChange={(e) => setSSOIdentifier(e.target.value)}
|
||||
type="text"
|
||||
placeholder="acme-123"
|
||||
isRequired
|
||||
autoComplete="email"
|
||||
id="email"
|
||||
className="h-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto mt-4 flex w-full min-w-[20rem] items-center justify-center rounded-md text-center md:min-w-[22rem] lg:w-1/6">
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isFullWidth
|
||||
className="h-14"
|
||||
>
|
||||
Continue with {type}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="mt-4 flex flex-row items-center justify-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setStep(0);
|
||||
}}
|
||||
type="button"
|
||||
className="mt-2 cursor-pointer text-sm text-bunker-300 duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"
|
||||
>
|
||||
{t("login.other-option")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { SSOStep } from "./SSOStep";
|
||||
5
frontend-v2/src/routes/login/-components/index.tsx
Normal file
5
frontend-v2/src/routes/login/-components/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export { InitialStep } from "./InitialStep";
|
||||
export { SSOStep } from "./SSOStep";
|
||||
|
||||
// SSO-specific step
|
||||
export { PasswordStep } from "./PasswordStep";
|
||||
81
frontend-v2/src/routes/login/index.tsx
Normal file
81
frontend-v2/src/routes/login/index.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
|
||||
import { InitialStep, SSOStep } from "./-components";
|
||||
|
||||
const LoginPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState(0);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
// TODO(rbr): move this to beforeload
|
||||
// const { navigateToSelectOrganization } = useNavigateToSelectOrganization();
|
||||
//
|
||||
// const queryParams = new URLSearchParams(window.location.search);
|
||||
//
|
||||
// useEffect(() => {
|
||||
// // TODO(akhilmhdh): workspace will be controlled by a workspace context
|
||||
// const handleRedirects = async () => {
|
||||
// try {
|
||||
// const callbackPort = queryParams?.get("callback_port");
|
||||
// // case: a callback port is set, meaning it's a cli login request: redirect to select org with callback port
|
||||
// if (callbackPort) {
|
||||
// navigateToSelectOrganization(callbackPort);
|
||||
// } else {
|
||||
// // case: no callback port, meaning it's a regular login request: redirect to select org
|
||||
// navigateToSelectOrganization();
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.log("Error - Not logged in yet");
|
||||
// }
|
||||
// };
|
||||
// if (isLoggedIn()) {
|
||||
// handleRedirects();
|
||||
// }
|
||||
// }, []);
|
||||
|
||||
const renderView = () => {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return (
|
||||
<InitialStep
|
||||
setStep={setStep}
|
||||
email={email}
|
||||
setEmail={setEmail}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return <SSOStep setStep={setStep} type="SAML" />;
|
||||
case 3:
|
||||
return <SSOStep setStep={setStep} type="OIDC" />;
|
||||
default:
|
||||
return <div />;
|
||||
}
|
||||
};
|
||||
|
||||
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 px-6">
|
||||
<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>
|
||||
<Link to="/">
|
||||
<div className="mb-4 mt-20 flex justify-center">
|
||||
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
|
||||
</div>
|
||||
</Link>
|
||||
<div className="pb-28">{renderView()}</div>;
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/login/")({
|
||||
component: LoginPage
|
||||
});
|
||||
157
frontend-v2/src/routes/login/ldap/index.tsx
Normal file
157
frontend-v2/src/routes/login/ldap/index.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useServerConfig } from "@app/context";
|
||||
import { useState } from "react";
|
||||
import { loginLDAPRedirect } from "@app/hooks/api/auth/queries";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Input, Button } from "@app/components/v2";
|
||||
|
||||
const LoginLDAPPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { config } = useServerConfig();
|
||||
const queryParams = new URLSearchParams(window.location.search);
|
||||
const passedOrgSlug = queryParams.get("organizationSlug");
|
||||
const passedUsername = queryParams.get("username");
|
||||
|
||||
const [organizationSlug, setOrganizationSlug] = useState(
|
||||
config.defaultAuthOrgSlug || passedOrgSlug || ""
|
||||
);
|
||||
const [username, setUsername] = useState(passedUsername || "");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const handleSubmission = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const { nextUrl } = await loginLDAPRedirect({
|
||||
organizationSlug,
|
||||
username,
|
||||
password
|
||||
});
|
||||
|
||||
if (!nextUrl) {
|
||||
createNotification({
|
||||
text: "Login unsuccessful. Double-check your credentials and try again.",
|
||||
type: "error"
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
createNotification({
|
||||
text: "Successfully logged in",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
window.open(nextUrl);
|
||||
window.close();
|
||||
} catch {
|
||||
createNotification({
|
||||
text: "Login unsuccessful. Double-check your credentials and try again.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: add callback port support
|
||||
|
||||
// const callbackPort = queryParams.get("callback_port");
|
||||
// window.open(`/api/v1/ldap/redirect/saml2/${ssoIdentifier}${callbackPort ? `?callback_port=${callbackPort}` : ""}`);
|
||||
// window.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col justify-center bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700 px-6 pb-28">
|
||||
<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>
|
||||
<Link to="/">
|
||||
<div className="mb-4 mt-20 flex justify-center">
|
||||
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
|
||||
</div>
|
||||
</Link>
|
||||
<div className="mx-auto w-full max-w-md md:px-6">
|
||||
<p className="mx-auto mb-6 mb-8 flex w-max justify-center bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
|
||||
What's your LDAP Login?
|
||||
</p>
|
||||
<form onSubmit={handleSubmission}>
|
||||
{!config.defaultAuthOrgSlug && !passedOrgSlug && (
|
||||
<div className="relative mx-auto flex max-h-24 w-1/4 w-full min-w-[20rem] items-center justify-center rounded-lg md:max-h-28 md:min-w-[22rem] lg:w-1/6">
|
||||
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
|
||||
<Input
|
||||
value={organizationSlug}
|
||||
onChange={(e) => setOrganizationSlug(e.target.value)}
|
||||
type="text"
|
||||
placeholder="Enter your organization slug..."
|
||||
isRequired
|
||||
autoComplete="email"
|
||||
id="email"
|
||||
className="h-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative mx-auto mt-2 flex max-h-24 w-1/4 w-full min-w-[20rem] items-center justify-center rounded-lg md:max-h-28 md:min-w-[22rem] lg:w-1/6">
|
||||
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
type="text"
|
||||
placeholder="Enter your LDAP username..."
|
||||
isRequired
|
||||
autoComplete="email"
|
||||
id="email"
|
||||
className="h-12"
|
||||
isDisabled={passedUsername !== null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mx-auto mt-2 flex max-h-24 w-1/4 w-full min-w-[20rem] items-center justify-center rounded-lg md:max-h-28 md:min-w-[22rem] lg:w-1/6">
|
||||
<div className="flex max-h-24 w-full items-center justify-center rounded-lg md:max-h-28">
|
||||
<Input
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
type="password"
|
||||
placeholder="Enter your LDAP password..."
|
||||
isRequired
|
||||
autoComplete="current-password"
|
||||
id="current-password"
|
||||
className="select:-webkit-autofill:focus h-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto mt-4 flex w-1/4 w-full min-w-[20rem] items-center justify-center rounded-md text-center md:min-w-[22rem] lg:w-1/6">
|
||||
<Button
|
||||
type="submit"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
isFullWidth
|
||||
className="h-14"
|
||||
>
|
||||
{t("login.login")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="mt-4 flex flex-row items-center justify-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate({ to: "/login" });
|
||||
}}
|
||||
type="button"
|
||||
className="mt-2 cursor-pointer text-sm text-bunker-300 duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"
|
||||
>
|
||||
{t("login.other-option")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/login/ldap/")({
|
||||
component: LoginLDAPPage
|
||||
});
|
||||
15
frontend-v2/src/routes/login/provider/error.tsx
Normal file
15
frontend-v2/src/routes/login/provider/error.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const LoginProviderError = () => {
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem("PROVIDER_AUTH_ERROR", "err");
|
||||
window.close();
|
||||
}, []);
|
||||
|
||||
return <div />;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/login/provider/error")({
|
||||
component: LoginProviderError
|
||||
});
|
||||
19
frontend-v2/src/routes/login/provider/success.tsx
Normal file
19
frontend-v2/src/routes/login/provider/success.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
||||
import { createFileRoute, useSearch } from "@tanstack/react-router";
|
||||
|
||||
const LoginProviderSuccess = () => {
|
||||
const search = useSearch({ from: "/login/provider/success" });
|
||||
|
||||
useEffect(() => {
|
||||
SecurityClient.setProviderAuthToken(search.token);
|
||||
window.close();
|
||||
}, []);
|
||||
|
||||
return <div />;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/login/provider/success")({
|
||||
component: LoginProviderSuccess
|
||||
});
|
||||
281
frontend-v2/src/routes/login/select-organization/index.tsx
Normal file
281
frontend-v2/src/routes/login/select-organization/index.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import axios from "axios";
|
||||
import { addSeconds, formatISO } from "date-fns";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
|
||||
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 { useToggle } from "@app/hooks";
|
||||
import {
|
||||
useGetOrganizations,
|
||||
useGetUser,
|
||||
useLogoutUser,
|
||||
useSelectOrganization
|
||||
} from "@app/hooks/api";
|
||||
import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types";
|
||||
import { Organization } from "@app/hooks/api/types";
|
||||
import { AuthMethod } from "@app/hooks/api/users/types";
|
||||
import { getAuthToken, isLoggedIn } from "@app/hooks/api/reactQuery";
|
||||
import { navigateUserToOrg } from "../-components/Login.utils";
|
||||
import { Mfa } from "../-components/Mfa";
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectOrganizationPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const organizations = useGetOrganizations();
|
||||
const selectOrg = useSelectOrganization();
|
||||
const { data: user, isLoading: 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 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) => {
|
||||
if (organization.authEnforced) {
|
||||
// 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.open(url);
|
||||
window.close();
|
||||
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.isLoading || !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: "/org/none" });
|
||||
} else if (organizations.data.length === 1) {
|
||||
if (callbackPort) {
|
||||
handleCliRedirect();
|
||||
setIsInitialOrgCheckLoading(false);
|
||||
} else {
|
||||
handleSelectOrganization(organizations.data[0]);
|
||||
}
|
||||
} else {
|
||||
setIsInitialOrgCheckLoading(false);
|
||||
}
|
||||
}, [organizations.isLoading, 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 href="/">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<img src="/images/gradientLogo.svg" height={90} width={120} 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.isLoading ? (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/login/select-organization/")({
|
||||
component: SelectOrganizationPage
|
||||
});
|
||||
63
frontend-v2/src/routes/login/sso/index.tsx
Normal file
63
frontend-v2/src/routes/login/sso/index.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { createFileRoute, Link, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { PasswordStep } from "../-components";
|
||||
|
||||
const LoginSSOPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const search = useSearch({ from: "/login/sso" });
|
||||
const token = search.token as string;
|
||||
const [step, setStep] = useState(0);
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const { username, isUserCompleted } = jwtDecode(token) as any;
|
||||
|
||||
useEffect(() => {
|
||||
if (isUserCompleted) {
|
||||
setStep(1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderView = () => {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return <div />;
|
||||
case 1:
|
||||
return (
|
||||
<PasswordStep
|
||||
providerAuthToken={token}
|
||||
email={username}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <div />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col justify-center bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700 px-6 pb-28">
|
||||
<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>
|
||||
<Link href="/">
|
||||
<div className="mb-4 mt-20 flex justify-center">
|
||||
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
|
||||
</div>
|
||||
</Link>
|
||||
<div>{renderView()}</div>;
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/login/sso/")({
|
||||
component: LoginSSOPage
|
||||
});
|
||||
104
frontend-v2/src/services/KeyService.ts
Normal file
104
frontend-v2/src/services/KeyService.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
decryptAssymmetric,
|
||||
encryptAssymmetric
|
||||
} from "@app/components/utilities/cryptography/crypto";
|
||||
import { decryptPrivateKeyHelper } from "@app/helpers/key";
|
||||
|
||||
/**
|
||||
* Class to handle key actions
|
||||
* TODO: in future, all private key-related encryption operations
|
||||
* must pass through this class
|
||||
*/
|
||||
class KeyService {
|
||||
private static privateKey: string = "";
|
||||
|
||||
static setPrivateKey(privateKey: string) {
|
||||
KeyService.privateKey = privateKey;
|
||||
}
|
||||
|
||||
/** Return the user's decrypted private key
|
||||
* @param {Object} obj
|
||||
* @param {Number} obj.encryptionVersion
|
||||
* @param {String} obj.encryptedPrivateKey
|
||||
* @param {String} obj.iv
|
||||
* @param {String} obj.tag
|
||||
* @param {String} obj.password
|
||||
* @param {String} obj.salt
|
||||
* @param {String} obj.protectedKey
|
||||
* @param {String} obj.protectedKeyIV
|
||||
* @param {String} obj.protectedKeyTag
|
||||
* @returns {String} privateKey - decrypted private key
|
||||
*/
|
||||
static async decryptPrivateKey({
|
||||
encryptionVersion,
|
||||
encryptedPrivateKey,
|
||||
iv,
|
||||
tag,
|
||||
password,
|
||||
salt,
|
||||
protectedKey,
|
||||
protectedKeyIV,
|
||||
protectedKeyTag
|
||||
}: {
|
||||
encryptionVersion: number;
|
||||
encryptedPrivateKey: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
password: string;
|
||||
salt: string;
|
||||
protectedKey?: string;
|
||||
protectedKeyIV?: string;
|
||||
protectedKeyTag?: string;
|
||||
}) {
|
||||
return decryptPrivateKeyHelper({
|
||||
encryptionVersion,
|
||||
encryptedPrivateKey,
|
||||
iv,
|
||||
tag,
|
||||
password,
|
||||
salt,
|
||||
protectedKey,
|
||||
protectedKeyIV,
|
||||
protectedKeyTag
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return [plaintext] encrypted by the user's private key
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.plaintext - plaintext to encrypt
|
||||
*/
|
||||
static encryptWithPrivateKey({ plaintext, publicKey }: { plaintext: string; publicKey: string }) {
|
||||
return encryptAssymmetric({
|
||||
plaintext,
|
||||
publicKey,
|
||||
privateKey: KeyService.privateKey
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return [ciphertext] decrypted by the user's private key
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.ciphertext - ciphertext to decrypt
|
||||
* @param {String} obj.ciphertext - iv of ciphertext
|
||||
* @param {String} obj.ciphertext - tag of ciphertext
|
||||
*/
|
||||
static decryptWithPrivateKey({
|
||||
ciphertext,
|
||||
nonce,
|
||||
publicKey
|
||||
}: {
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
publicKey: string;
|
||||
}) {
|
||||
return decryptAssymmetric({
|
||||
ciphertext,
|
||||
nonce,
|
||||
publicKey,
|
||||
privateKey: KeyService.privateKey
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default KeyService;
|
||||
19
frontend-v2/src/services/ProjectService.ts
Normal file
19
frontend-v2/src/services/ProjectService.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { initProjectHelper } from "@app/helpers/project";
|
||||
|
||||
class ProjectService {
|
||||
/**
|
||||
* Create and initialize a new project in organization with id [organizationId]
|
||||
* Note: current user should be a member of the organization
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.organizationId - id of organization
|
||||
* @param {String} obj.projectName - name of new project
|
||||
* @returns {Project} project - new project
|
||||
*/
|
||||
static async initProject({ projectName }: { projectName: string }) {
|
||||
return initProjectHelper({
|
||||
projectName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default ProjectService;
|
||||
4
frontend-v2/src/services/index.ts
Normal file
4
frontend-v2/src/services/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import KeyService from "./KeyService";
|
||||
import ProjectService from "./ProjectService";
|
||||
|
||||
export { KeyService, ProjectService };
|
||||
@@ -3,7 +3,7 @@
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
import { nodePolyfills } from "vite-plugin-node-polyfills";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [TanStackRouterVite(), react()]
|
||||
plugins: [
|
||||
tsconfigPaths(),
|
||||
nodePolyfills({
|
||||
globals: {
|
||||
Buffer: true
|
||||
}
|
||||
}),
|
||||
wasm(),
|
||||
topLevelAwait(),
|
||||
TanStackRouterVite(),
|
||||
react()
|
||||
]
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user