Merge remote-tracking branch 'origin/main' into misc/improved-helper-text-for-gcp-sa-field

This commit is contained in:
Sheen Capadngan
2025-01-29 17:43:48 +08:00
43 changed files with 1111 additions and 101 deletions
@@ -136,7 +136,8 @@ export const PasswordResetPage = () => {
encryptedPrivateKeyTag,
salt: result.salt,
verifier: result.verifier,
verificationToken
verificationToken,
password: newPassword
});
navigate({ to: "/login" });
@@ -0,0 +1,349 @@
import crypto from "crypto";
import { FormEvent, useState } from "react";
import { faCheck, faEye, faEyeSlash, faKey, faX } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import jsrp from "jsrp";
import { createNotification } from "@app/components/notifications";
import passwordCheck from "@app/components/utilities/checks/password/PasswordCheck";
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto";
import { Button, Card, CardTitle, FormControl, Input } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { useSetupPassword } from "@app/hooks/api/auth/queries";
// eslint-disable-next-line new-cap
const client = new jsrp.client();
export const PasswordSetupPage = () => {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [passwordsMatch, setPasswordsMatch] = useState(true);
const [passwordErrorTooShort, setPasswordErrorTooShort] = useState(true);
const [passwordErrorTooLong, setPasswordErrorTooLong] = useState(false);
const [passwordErrorNoLetterChar, setPasswordErrorNoLetterChar] = useState(true);
const [passwordErrorNoNumOrSpecialChar, setPasswordErrorNoNumOrSpecialChar] = useState(true);
const [passwordErrorRepeatedChar, setPasswordErrorRepeatedChar] = useState(false);
const [passwordErrorEscapeChar, setPasswordErrorEscapeChar] = useState(false);
const [passwordErrorLowEntropy, setPasswordErrorLowEntropy] = useState(false);
const [passwordErrorBreached, setPasswordErrorBreached] = useState(false);
const [isRedirecting, setIsRedirecting] = useState(false);
const search = useSearch({ from: ROUTE_PATHS.Auth.PasswordSetupPage.id });
const navigate = useNavigate();
const setupPassword = useSetupPassword();
const parsedUrl = search;
const token = parsedUrl.token as string;
const email = (parsedUrl.to as string)?.replace(" ", "+").trim();
const handleSetPassword = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const errorCheck = await passwordCheck({
password,
setPasswordErrorTooShort,
setPasswordErrorTooLong,
setPasswordErrorNoLetterChar,
setPasswordErrorNoNumOrSpecialChar,
setPasswordErrorRepeatedChar,
setPasswordErrorEscapeChar,
setPasswordErrorLowEntropy,
setPasswordErrorBreached
});
if (password !== confirmPassword) {
setPasswordsMatch(false);
return;
}
setPasswordsMatch(true);
if (!errorCheck) {
client.init(
{
username: email,
password
},
async () => {
client.createVerifier(async (_err: any, result: { salt: string; verifier: string }) => {
const derivedKey = await deriveArgonKey({
password,
salt: result.salt,
mem: 65536,
time: 3,
parallelism: 1,
hashLen: 32
});
if (!derivedKey) throw new Error("Failed to derive key from password");
const key = crypto.randomBytes(32);
// create encrypted private key by encrypting the private
// key with the symmetric key [key]
const {
ciphertext: encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag
} = Aes256Gcm.encrypt({
text: localStorage.getItem("PRIVATE_KEY") as string,
secret: key
});
// create the protected key by encrypting the symmetric key
// [key] with the derived key
const {
ciphertext: protectedKey,
iv: protectedKeyIV,
tag: protectedKeyTag
} = Aes256Gcm.encrypt({
text: key.toString("hex"),
secret: Buffer.from(derivedKey.hash)
});
try {
await setupPassword.mutateAsync({
protectedKey,
protectedKeyIV,
protectedKeyTag,
encryptedPrivateKey,
encryptedPrivateKeyIV,
encryptedPrivateKeyTag,
salt: result.salt,
verifier: result.verifier,
token,
password
});
setIsRedirecting(true);
createNotification({
type: "success",
title: "Password successfully set",
text: "Redirecting to login..."
});
setTimeout(() => {
window.location.href = "/login";
}, 3000);
} catch (error) {
createNotification({
type: "error",
text: (error as Error).message ?? "Error setting password"
});
navigate({ to: "/personal-settings" });
}
});
}
);
}
};
const isInvalidPassword =
passwordErrorTooShort ||
passwordErrorTooLong ||
passwordErrorNoLetterChar ||
passwordErrorNoNumOrSpecialChar ||
passwordErrorRepeatedChar ||
passwordErrorEscapeChar ||
passwordErrorLowEntropy ||
passwordErrorBreached;
return (
<div className="flex h-screen w-full flex-col items-center justify-center bg-bunker-800">
<form onSubmit={handleSetPassword}>
<Card className="flex w-full max-w-lg flex-col rounded-md border border-mineshaft-600 px-8 py-4">
<CardTitle
className="p-0 pb-4 pt-2 text-left text-xl"
subTitle="Make sure to store your password somewhere safe."
>
<div className="flex flex-row items-center">
<div className="flex items-center pb-0.5">
<FontAwesomeIcon icon={faKey} />
</div>
<span className="ml-2.5">Set Password</span>
</div>
</CardTitle>
<FormControl label="Password">
<Input
value={password}
type={showPassword ? "text" : "password"}
autoComplete="new-password"
onChange={(e) => {
setPassword(e.target.value);
passwordCheck({
password: e.target.value,
setPasswordErrorTooShort,
setPasswordErrorTooLong,
setPasswordErrorNoLetterChar,
setPasswordErrorNoNumOrSpecialChar,
setPasswordErrorRepeatedChar,
setPasswordErrorEscapeChar,
setPasswordErrorLowEntropy,
setPasswordErrorBreached
});
}}
rightIcon={
<button
type="button"
onClick={() => {
setShowPassword((prev) => !prev);
}}
className="cursor-pointer self-end text-gray-400"
>
{showPassword ? (
<FontAwesomeIcon size="sm" icon={faEyeSlash} />
) : (
<FontAwesomeIcon size="sm" icon={faEye} />
)}
</button>
}
/>
</FormControl>
<FormControl
label="Confirm Password"
errorText="Passwords must match"
isError={!passwordsMatch}
>
<Input
value={confirmPassword}
type={showConfirmPassword ? "text" : "password"}
autoComplete="new-password"
onChange={(e) => setConfirmPassword(e.target.value)}
rightIcon={
<button
type="button"
onClick={() => {
setShowConfirmPassword((prev) => !prev);
}}
className="cursor-pointer self-end text-gray-400"
>
{showConfirmPassword ? (
<FontAwesomeIcon size="sm" icon={faEyeSlash} />
) : (
<FontAwesomeIcon size="sm" icon={faEye} />
)}
</button>
}
/>
</FormControl>
<div className="mb-4 flex w-full max-w-md flex-col items-start rounded-md bg-mineshaft-700 px-2 py-2 transition-opacity duration-100">
<div className="mb-1 text-sm text-gray-400">Password must contain:</div>
<div className="ml-1 flex flex-row items-center justify-start">
{passwordErrorTooShort ? (
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
) : (
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
)}
<div
className={`${passwordErrorTooShort ? "text-gray-400" : "text-gray-600"} text-sm`}
>
at least 14 characters
</div>
</div>
<div className="ml-1 flex flex-row items-center justify-start">
{passwordErrorTooLong ? (
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
) : (
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
)}
<div
className={`${passwordErrorTooLong ? "text-gray-400" : "text-gray-600"} text-sm`}
>
at most 100 characters
</div>
</div>
<div className="ml-1 flex flex-row items-center justify-start">
{passwordErrorNoLetterChar ? (
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
) : (
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
)}
<div
className={`${passwordErrorNoLetterChar ? "text-gray-400" : "text-gray-600"} text-sm`}
>
at least 1 letter character
</div>
</div>
<div className="ml-1 flex flex-row items-center justify-start">
{passwordErrorNoNumOrSpecialChar ? (
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
) : (
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
)}
<div
className={`${
passwordErrorNoNumOrSpecialChar ? "text-gray-400" : "text-gray-600"
} text-sm`}
>
at least 1 number or special character
</div>
</div>
<div className="ml-1 flex flex-row items-center justify-start">
{passwordErrorRepeatedChar ? (
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
) : (
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
)}
<div
className={`${passwordErrorRepeatedChar ? "text-gray-400" : "text-gray-600"} text-sm`}
>
at most 3 repeated, consecutive characters
</div>
</div>
<div className="ml-1 flex flex-row items-center justify-start">
{passwordErrorEscapeChar ? (
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
) : (
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
)}
<div
className={`${passwordErrorEscapeChar ? "text-gray-400" : "text-gray-600"} text-sm`}
>
no escape characters
</div>
</div>
<div className="ml-1 flex flex-row items-center justify-start">
{passwordErrorLowEntropy ? (
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
) : (
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
)}
<div
className={`${passwordErrorLowEntropy ? "text-gray-400" : "text-gray-600"} text-sm`}
>
no personal information
</div>
</div>
<div className="ml-1 flex flex-row items-center justify-start">
{passwordErrorBreached ? (
<FontAwesomeIcon icon={faX} className="text-md mr-2.5 text-red" />
) : (
<FontAwesomeIcon icon={faCheck} className="text-md mr-2 text-primary" />
)}
<div
className={`${passwordErrorBreached ? "text-gray-400" : "text-gray-600"} text-sm`}
>
password not found in a data breach.
</div>
</div>
</div>
<Button
isDisabled={isInvalidPassword || setupPassword.isPending || isRedirecting}
colorSchema="secondary"
type="submit"
isLoading={setupPassword.isPending}
>
Submit
</Button>
</Card>
</form>
</div>
);
};
@@ -0,0 +1,15 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { PasswordSetupPage } from "./PasswordSetupPage";
const PasswordSetupPageQueryParamsSchema = z.object({
token: z.string(),
to: z.string()
});
export const Route = createFileRoute("/_authenticate/password-setup")({
component: PasswordSetupPage,
validateSearch: zodValidator(PasswordSetupPageQueryParamsSchema)
});
@@ -1,12 +1,13 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { ROUTE_PATHS } from "@app/const/routes";
import { userKeys } from "@app/hooks/api";
import { authKeys, fetchAuthToken } from "@app/hooks/api/auth/queries";
import { fetchUserDetails } from "@app/hooks/api/users/queries";
export const Route = createFileRoute("/_authenticate")({
beforeLoad: async ({ context }) => {
beforeLoad: async ({ context, location }) => {
if (!context.serverConfig.initialized) {
throw redirect({ to: "/admin/signup" });
}
@@ -26,7 +27,7 @@ export const Route = createFileRoute("/_authenticate")({
});
});
if (!data.organizationId) {
if (!data.organizationId && location.pathname !== ROUTE_PATHS.Auth.PasswordSetupPage.path) {
throw redirect({ to: "/login/select-organization" });
}
@@ -14,6 +14,7 @@ import {
DropdownMenuTrigger,
FilterableSelect,
FormControl,
Input,
Select,
SelectItem
} from "@app/components/v2";
@@ -22,6 +23,7 @@ import { useGetAuditLogActorFilterOpts, useGetUserWorkspaces } from "@app/hooks/
import { eventToNameMap, userAgentTTypeoNameMap } from "@app/hooks/api/auditLogs/constants";
import { ActorType, EventType } from "@app/hooks/api/auditLogs/enums";
import { Actor } from "@app/hooks/api/auditLogs/types";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { AuditLogFilterFormData } from "./types";
@@ -50,6 +52,7 @@ export const LogsFilter = ({
className,
control,
reset,
setValue,
watch
}: Props) => {
const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false);
@@ -101,6 +104,7 @@ export const LogsFilter = ({
};
const selectedEventTypes = watch("eventType") as EventType[] | undefined;
const selectedProject = watch("project");
return (
<div
@@ -109,30 +113,48 @@ export const LogsFilter = ({
className
)}
>
{isOrgAuditLogs && workspacesInOrg.length > 0 && (
<Controller
control={control}
name="project"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Project"
errorText={error?.message}
isError={Boolean(error)}
className="mr-12 w-64"
>
<FilterableSelect
value={value}
isClearable
onChange={onChange}
placeholder="Select a project..."
options={workspacesInOrg.map(({ name, id }) => ({ name, id }))}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
)}
<div className="flex items-center gap-4">
{isOrgAuditLogs && workspacesInOrg.length > 0 && (
<Controller
control={control}
name="project"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Project"
errorText={error?.message}
isError={Boolean(error)}
className="w-64"
>
<FilterableSelect
value={value}
isClearable
onChange={(e) => {
if (e === null) {
setValue("secretPath", "");
}
onChange(e);
}}
placeholder="Select a project..."
options={workspacesInOrg.map(({ name, id, type }) => ({ name, id, type }))}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
)}
{selectedProject?.type === ProjectType.SecretManager && (
<Controller
control={control}
name="secretPath"
render={({ field: { onChange, value, ...field } }) => (
<FormControl label="Secret path" className="w-40">
<Input {...field} value={value} onChange={(e) => onChange(e.target.value)} />
</FormControl>
)}
/>
)}
</div>
<div className="mt-1 flex items-center space-x-2">
<Controller
control={control}
@@ -5,6 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
import { withPermission } from "@app/hoc";
import { useDebounce } from "@app/hooks";
import { ActorType, EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
import { usePopUp } from "@app/hooks/usePopUp";
@@ -67,10 +68,13 @@ export const LogsSection = withPermission(
const userAgentType = watch("userAgentType") as UserAgentType | undefined;
const actor = watch("actor");
const projectId = watch("project")?.id;
const secretPath = watch("secretPath");
const startDate = watch("startDate");
const endDate = watch("endDate");
const [debouncedSecretPath] = useDebounce<string>(secretPath!, 500);
return (
<div>
{showFilters && (
@@ -90,6 +94,7 @@ export const LogsSection = withPermission(
isOrgAuditLogs={isOrgAuditLogs}
showActorColumn={!!showActorColumn}
filter={{
secretPath: debouncedSecretPath || undefined,
eventMetadata: presets?.eventMetadata,
projectId,
actorType: presets?.actorType,
@@ -1,14 +1,19 @@
import { z } from "zod";
import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
import { ProjectType } from "@app/hooks/api/workspace/types";
export const auditLogFilterFormSchema = z
.object({
eventMetadata: z.object({}).optional(),
project: z.object({ id: z.string(), name: z.string() }).optional().nullable(),
project: z
.object({ id: z.string(), name: z.string(), type: z.nativeEnum(ProjectType) })
.optional()
.nullable(),
eventType: z.nativeEnum(EventType).array(),
actor: z.string().optional(),
userAgentType: z.nativeEnum(UserAgentType),
secretPath: z.string().optional(),
startDate: z.date().optional(),
endDate: z.date().optional(),
page: z.coerce.number().optional(),
@@ -5,7 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Badge, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { Badge, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { IntegrationsListPageTabs } from "@app/types/integrations";
@@ -45,14 +45,12 @@ export const IntegrationsListPage = () => {
<meta name="og:description" content={t("integrations.description") as string} />
</Helmet>
<div className="container relative mx-auto max-w-7xl pb-12 text-white">
<div className="mx-6 mb-8">
<div className="mb-4 mt-6 flex flex-col items-start justify-between px-2 text-xl">
<h1 className="text-3xl font-semibold">Integrations</h1>
<p className="text-base text-bunker-300">
Manage integrations with third-party services.
</p>
</div>
<div className="mx-2 mb-4 flex flex-col rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5">
<div className="mb-8">
<PageHeader
title="Integrations"
description="Manage integrations with third-party services."
/>
<div className="mb-4 mt-4 flex flex-col rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5">
<div className="mb-1 flex items-center text-sm">
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="mr-1 text-primary" />
Integrations Update
@@ -73,7 +73,7 @@ const PageContent = () => {
return (
<>
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 font-inter text-white">
<div className="mx-auto mb-6 w-full max-w-7xl px-6 py-6">
<div className="mx-auto mb-6 w-full max-w-7xl">
<Button
variant="link"
type="submit"
@@ -89,7 +89,6 @@ const PageContent = () => {
}
});
}}
className="mb-4"
>
Secret Syncs
</Button>
@@ -65,7 +65,7 @@ export const AzureDevopsAuthorizePage = () => {
<div className="flex flex-row items-center">
<div className="flex items-center">
<img
src="/images/integrations/Amazon Web Services.png"
src="/images/integrations/Microsoft Azure.png"
height={35}
width={35}
alt="Azure DevOps logo"
@@ -106,7 +106,7 @@ export const AzureDevopsConfigurePage = () => {
<div className="flex flex-row items-center">
<div className="flex items-center">
<img
src="/images/integrations/Amazon Web Services.png"
src="/images/integrations/Microsoft Azure.png"
height={35}
width={35}
alt="Azure DevOps logo"
@@ -47,7 +47,12 @@ export function AzureKeyVaultAuthorizePage() {
>
<div className="flex flex-row items-center">
<div className="flex items-center pb-0.5">
<img src="/images/integrations/GitHub.png" height={30} width={30} alt="Github logo" />
<img
src="/images/integrations/Microsoft Azure.png"
height={30}
width={30}
alt="Azure Key Vault logo"
/>
</div>
<span className="ml-2.5">Azure Key Vault Integration </span>
<a
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Helmet } from "react-helmet";
import {
faArrowUpRightFromSquare,
@@ -25,7 +25,8 @@ import { useCreateIntegration } from "@app/hooks/api";
import {
useGetIntegrationAuthApps,
useGetIntegrationAuthById,
useGetIntegrationAuthVercelBranches
useGetIntegrationAuthVercelBranches,
useGetIntegrationAuthVercelCustomEnvironments
} from "@app/hooks/api/integrationAuth";
import { IntegrationSyncBehavior } from "@app/hooks/api/integrations/types";
@@ -75,6 +76,11 @@ export const VercelConfigurePage = () => {
teamId: integrationAuth?.teamId as string
});
const { data: customEnvironments } = useGetIntegrationAuthVercelCustomEnvironments({
teamId: integrationAuth?.teamId as string,
integrationAuthId: integrationAuthId as string
});
const { data: branches } = useGetIntegrationAuthVercelBranches({
integrationAuthId: integrationAuthId as string,
appId: targetAppId
@@ -135,6 +141,26 @@ export const VercelConfigurePage = () => {
}
};
const selectedVercelEnvironments = useMemo(() => {
let selectedEnvironments = vercelEnvironments;
const environments = customEnvironments?.find(
(e) => e.appId === targetAppId
)?.customEnvironments;
if (environments && environments.length > 0) {
selectedEnvironments = [
...selectedEnvironments,
...environments.map((env) => ({
name: env.slug,
slug: env.id
}))
];
}
return selectedEnvironments;
}, [targetAppId, customEnvironments]);
return integrationAuth &&
selectedSourceEnvironment &&
integrationAuthApps &&
@@ -210,7 +236,13 @@ export const VercelConfigurePage = () => {
>
<Select
value={targetAppId}
onValueChange={(val) => setTargetAppId(val)}
onValueChange={(val) => {
if (vercelEnvironments.every((env) => env.slug !== targetEnvironment)) {
setTargetEnvironment(vercelEnvironments[0].slug);
}
setTargetAppId(val);
}}
className="w-full border border-mineshaft-500"
isDisabled={integrationAuthApps.length === 0}
>
@@ -236,7 +268,7 @@ export const VercelConfigurePage = () => {
onValueChange={(val) => setTargetEnvironment(val)}
className="w-full border border-mineshaft-500"
>
{vercelEnvironments.map((vercelEnvironment) => (
{selectedVercelEnvironments.map((vercelEnvironment) => (
<SelectItem
value={vercelEnvironment.slug}
key={`target-environment-${vercelEnvironment.slug}`}
@@ -11,6 +11,7 @@ import attemptChangePassword from "@app/components/utilities/attemptChangePasswo
import checkPassword from "@app/components/utilities/checks/password/checkPassword";
import { Button, FormControl, Input } from "@app/components/v2";
import { useUser } from "@app/context";
import { useSendPasswordSetupEmail } from "@app/hooks/api/auth/queries";
type Errors = {
tooShort?: string;
@@ -45,6 +46,7 @@ export const ChangePasswordSection = () => {
});
const [errors, setErrors] = useState<Errors>({});
const [isLoading, setIsLoading] = useState(false);
const sendSetupPasswordEmail = useSendPasswordSetupEmail();
const onFormSubmit = async ({ oldPassword, newPassword }: FormData) => {
try {
@@ -80,6 +82,24 @@ export const ChangePasswordSection = () => {
}
};
const onSetupPassword = async () => {
try {
await sendSetupPasswordEmail.mutateAsync();
createNotification({
title: "Password setup verification email sent",
text: "Check your email to confirm password setup",
type: "info"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to send password setup email",
type: "error"
});
}
};
return (
<form
onSubmit={handleSubmit(onFormSubmit)}
@@ -142,6 +162,16 @@ export const ChangePasswordSection = () => {
<Button type="submit" colorSchema="secondary" isLoading={isLoading} isDisabled={isLoading}>
Save
</Button>
<p className="mt-2 font-inter text-sm text-mineshaft-400">
Need to setup a password?{" "}
<button
onClick={onSetupPassword}
type="button"
className="underline underline-offset-2 hover:text-mineshaft-200"
>
Click here
</button>
</p>
</form>
);
};