mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-12 07:53:00 +00:00
split up register, session for passkeys
This commit is contained in:
@@ -10,6 +10,7 @@ export default async function Page({
|
||||
searchParams: Record<string | number | symbol, string | undefined>;
|
||||
}) {
|
||||
const { loginName } = searchParams;
|
||||
|
||||
const sessionFactors = await loadSession(loginName);
|
||||
|
||||
async function loadSession(loginName?: string) {
|
||||
|
||||
@@ -3,24 +3,50 @@ import {
|
||||
getPasswordComplexitySettings,
|
||||
server,
|
||||
} from "#/lib/zitadel";
|
||||
import RegisterForm from "#/ui/RegisterForm";
|
||||
import RegisterFormWithoutPassword from "#/ui/RegisterFormWithoutPassword";
|
||||
import SetPasswordForm from "#/ui/SetPasswordForm";
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string | number | symbol, string | undefined>;
|
||||
}) {
|
||||
const { firstname, lastname, email } = searchParams;
|
||||
|
||||
const setPassword = !!(firstname && lastname && email);
|
||||
|
||||
console.log(setPassword);
|
||||
|
||||
export default async function Page() {
|
||||
const legal = await getLegalAndSupportSettings(server);
|
||||
const passwordComplexitySettings = await getPasswordComplexitySettings(
|
||||
server
|
||||
);
|
||||
|
||||
return (
|
||||
console.log(legal);
|
||||
|
||||
return setPassword ? (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>Set Password</h1>
|
||||
<p className="ztdl-p">Set the password for your account</p>
|
||||
|
||||
{legal && passwordComplexitySettings && (
|
||||
<SetPasswordForm
|
||||
passwordComplexitySettings={passwordComplexitySettings}
|
||||
email={email}
|
||||
firstname={firstname}
|
||||
lastname={lastname}
|
||||
></SetPasswordForm>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>Register</h1>
|
||||
<p className="ztdl-p">Create your ZITADEL account.</p>
|
||||
|
||||
{legal && passwordComplexitySettings && (
|
||||
<RegisterForm
|
||||
<RegisterFormWithoutPassword
|
||||
legal={legal}
|
||||
passwordComplexitySettings={passwordComplexitySettings}
|
||||
></RegisterForm>
|
||||
></RegisterFormWithoutPassword>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
27
apps/login/app/(login)/signup/page.tsx
Normal file
27
apps/login/app/(login)/signup/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
getLegalAndSupportSettings,
|
||||
getPasswordComplexitySettings,
|
||||
server,
|
||||
} from "#/lib/zitadel";
|
||||
import RegisterForm from "#/ui/RegisterForm";
|
||||
|
||||
export default async function Page() {
|
||||
const legal = await getLegalAndSupportSettings(server);
|
||||
const passwordComplexitySettings = await getPasswordComplexitySettings(
|
||||
server
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>Register</h1>
|
||||
<p className="ztdl-p">Create your ZITADEL account.</p>
|
||||
|
||||
{legal && passwordComplexitySettings && (
|
||||
<RegisterForm
|
||||
legal={legal}
|
||||
passwordComplexitySettings={passwordComplexitySettings}
|
||||
></RegisterForm>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
apps/login/app/(login)/signup/success/page.tsx
Normal file
19
apps/login/app/(login)/signup/success/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Button, ButtonVariants } from "#/ui/Button";
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = {
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
};
|
||||
export default async function Page({ searchParams }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>Registration successful</h1>
|
||||
<p className="ztdl-p">You are registered.</p>
|
||||
|
||||
{`userId: ${searchParams["userid"]}`}
|
||||
<Link href="/register">
|
||||
<Button variant={ButtonVariants.Primary}>back</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export async function POST(request: NextRequest) {
|
||||
email: email,
|
||||
firstName,
|
||||
lastName,
|
||||
password: password,
|
||||
password: password ? password : undefined,
|
||||
});
|
||||
return NextResponse.json({ userId });
|
||||
} else {
|
||||
|
||||
@@ -35,7 +35,10 @@ export async function POST(request: NextRequest) {
|
||||
loginName: response.session?.factors?.user?.loginName ?? "",
|
||||
};
|
||||
return addSessionToCookie(sessionCookie).then(() => {
|
||||
return NextResponse.json({ factors: response?.session?.factors });
|
||||
return NextResponse.json({
|
||||
sessionId: createdSession.sessionId,
|
||||
factors: response?.session?.factors,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ZitadelServer,
|
||||
ZitadelServerOptions,
|
||||
user,
|
||||
management,
|
||||
settings,
|
||||
getServers,
|
||||
initializeServer,
|
||||
@@ -23,6 +24,8 @@ import {
|
||||
VerifyPasskeyRegistrationRequest,
|
||||
VerifyPasskeyRegistrationResponse,
|
||||
orgMetadata,
|
||||
SetUserPassword,
|
||||
SetHumanPasswordResponse,
|
||||
} from "@zitadel/server";
|
||||
import { Metadata } from "nice-grpc";
|
||||
|
||||
@@ -131,7 +134,7 @@ export type AddHumanUserData = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
password: string | undefined;
|
||||
};
|
||||
|
||||
export async function addHumanUser(
|
||||
@@ -141,12 +144,18 @@ export async function addHumanUser(
|
||||
const mgmt = user.getUser(server);
|
||||
return mgmt
|
||||
.addHumanUser(
|
||||
{
|
||||
email: { email },
|
||||
username: email,
|
||||
profile: { firstName, lastName },
|
||||
password: { password },
|
||||
},
|
||||
password
|
||||
? {
|
||||
email: { email },
|
||||
username: email,
|
||||
profile: { firstName, lastName },
|
||||
password: { password },
|
||||
}
|
||||
: {
|
||||
email: { email },
|
||||
username: email,
|
||||
profile: { firstName, lastName },
|
||||
},
|
||||
{}
|
||||
)
|
||||
.then((resp: AddHumanUserResponse) => {
|
||||
@@ -154,6 +163,19 @@ export async function addHumanUser(
|
||||
});
|
||||
}
|
||||
|
||||
export async function setHumanPassword(
|
||||
server: ZitadelServer,
|
||||
userId: string,
|
||||
password: string
|
||||
): Promise<string> {
|
||||
const mgmt = management.getManagement(server);
|
||||
return mgmt
|
||||
.setHumanPassword({ userId, password }, {})
|
||||
.then((resp: SetHumanPasswordResponse) => {
|
||||
return resp.userId;
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyEmail(
|
||||
server: ZitadelServer,
|
||||
userId: string,
|
||||
|
||||
105
apps/login/ui/AuthenticationMethodRadio.tsx
Normal file
105
apps/login/ui/AuthenticationMethodRadio.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { RadioGroup } from "@headlessui/react";
|
||||
|
||||
export const methods = [
|
||||
{
|
||||
name: "Passkeys",
|
||||
description: "Authenticate with your device.",
|
||||
},
|
||||
{
|
||||
name: "Password",
|
||||
description: "Authenticate with a password",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AuthenticationMethodRadio({
|
||||
selected,
|
||||
selectionChanged,
|
||||
}: {
|
||||
selected: any;
|
||||
selectionChanged: (value: any) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<RadioGroup value={selected} onChange={selectionChanged}>
|
||||
<RadioGroup.Label className="sr-only">Server size</RadioGroup.Label>
|
||||
<div className="grid grid-cols-2 space-x-2">
|
||||
{methods.map((method) => (
|
||||
<RadioGroup.Option
|
||||
key={method.name}
|
||||
value={method}
|
||||
className={({ active, checked }) =>
|
||||
`${
|
||||
active
|
||||
? "h-full ring-2 ring-opacity-60 ring-primary-light-500 dark:ring-white/20"
|
||||
: "h-full "
|
||||
}
|
||||
${
|
||||
checked
|
||||
? "bg-background-light-400 dark:bg-background-dark-400"
|
||||
: "bg-background-light-400 dark:bg-background-dark-400"
|
||||
}
|
||||
relative border boder-divider-light dark:border-divider-dark flex cursor-pointer rounded-lg px-5 py-4 focus:outline-none hover:shadow-md`
|
||||
}
|
||||
>
|
||||
{({ active, checked }) => (
|
||||
<>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="text-sm">
|
||||
<RadioGroup.Label
|
||||
as="p"
|
||||
className={`font-medium ${checked ? "" : ""}`}
|
||||
>
|
||||
{method.name}
|
||||
</RadioGroup.Label>
|
||||
<RadioGroup.Description
|
||||
as="span"
|
||||
className={`text-xs text-opacity-80 dark:text-opacity-80 inline ${
|
||||
checked ? "" : ""
|
||||
}`}
|
||||
>
|
||||
{method.description}
|
||||
<span aria-hidden="true">·</span>{" "}
|
||||
</RadioGroup.Description>
|
||||
</div>
|
||||
</div>
|
||||
{checked && (
|
||||
<div className="shrink-0 text-white">
|
||||
<CheckIcon className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon(props: any) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" {...props}>
|
||||
<circle
|
||||
className="fill-current text-black/50 dark:text-white/50"
|
||||
cx={12}
|
||||
cy={12}
|
||||
r={12}
|
||||
opacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M7 13l3 3 7-7"
|
||||
className="stroke-black dark:stroke-white"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
173
apps/login/ui/RegisterFormWithoutPassword.tsx
Normal file
173
apps/login/ui/RegisterFormWithoutPassword.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { LegalAndSupportSettings } from "@zitadel/server";
|
||||
import { useState } from "react";
|
||||
import { Button, ButtonVariants } from "./Button";
|
||||
import { TextInput } from "./Input";
|
||||
import { PrivacyPolicyCheckboxes } from "./PrivacyPolicyCheckboxes";
|
||||
import { FieldValues, useForm } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Spinner } from "./Spinner";
|
||||
import AuthenticationMethodRadio, {
|
||||
methods,
|
||||
} from "./AuthenticationMethodRadio";
|
||||
|
||||
type Inputs =
|
||||
| {
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
email: string;
|
||||
}
|
||||
| FieldValues;
|
||||
|
||||
type Props = {
|
||||
legal: LegalAndSupportSettings;
|
||||
};
|
||||
|
||||
export default function RegisterFormWithoutPassword({ legal }: Props) {
|
||||
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [selected, setSelected] = useState(methods[0]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function submitAndRegister(values: Inputs) {
|
||||
setLoading(true);
|
||||
const res = await fetch("/registeruser", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: values.email,
|
||||
firstName: values.firstname,
|
||||
lastName: values.lastname,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setLoading(false);
|
||||
throw new Error("Failed to register user");
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function createSessionWithLoginName(loginName: string) {
|
||||
setLoading(true);
|
||||
const res = await fetch("/session", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
loginName: loginName,
|
||||
}),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to set user");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function submitAndContinue(
|
||||
value: Inputs,
|
||||
withPassword: boolean = false
|
||||
) {
|
||||
return withPassword
|
||||
? router.push(`/register?` + new URLSearchParams(value))
|
||||
: submitAndRegister(value).then((resp: any) => {
|
||||
createSessionWithLoginName(value.email).then(({ factors }) => {
|
||||
return router.push(
|
||||
`/passkey/add?` +
|
||||
new URLSearchParams({ loginName: factors.user.loginName })
|
||||
);
|
||||
});
|
||||
});
|
||||
// .then((resp: any) => {
|
||||
// return router.push(`/verify?userID=${resp.userId}`);
|
||||
// });
|
||||
}
|
||||
|
||||
const { errors } = formState;
|
||||
|
||||
const [tosAndPolicyAccepted, setTosAndPolicyAccepted] = useState(false);
|
||||
|
||||
return (
|
||||
<form className="w-full">
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="">
|
||||
<TextInput
|
||||
type="firstname"
|
||||
autoComplete="firstname"
|
||||
required
|
||||
{...register("firstname", { required: "This field is required" })}
|
||||
label="First name"
|
||||
error={errors.firstname?.message as string}
|
||||
/>
|
||||
</div>
|
||||
<div className="">
|
||||
<TextInput
|
||||
type="lastname"
|
||||
autoComplete="lastname"
|
||||
required
|
||||
{...register("lastname", { required: "This field is required" })}
|
||||
label="Last name"
|
||||
error={errors.lastname?.message as string}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<TextInput
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
{...register("email", { required: "This field is required" })}
|
||||
label="E-mail"
|
||||
error={errors.email?.message as string}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{legal && (
|
||||
<PrivacyPolicyCheckboxes
|
||||
legal={legal}
|
||||
onChange={setTosAndPolicyAccepted}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="py-4">
|
||||
<AuthenticationMethodRadio
|
||||
selected={selected}
|
||||
selectionChanged={setSelected}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex w-full flex-row items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant={ButtonVariants.Secondary}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
back
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={loading || !formState.isValid || !tosAndPolicyAccepted}
|
||||
onClick={handleSubmit((values) =>
|
||||
submitAndContinue(values, selected === methods[0] ? false : true)
|
||||
)}
|
||||
>
|
||||
{loading && <Spinner className="h-5 w-5 mr-2" />}
|
||||
continue
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -146,41 +146,6 @@ export default function RegisterPasskey({ sessionId }: Props) {
|
||||
console.log(data);
|
||||
|
||||
return submitVerify(passkeyId, "name", data, sessionId);
|
||||
// if (this.type === U2FComponentDestination.MFA) {
|
||||
// this.service
|
||||
// .verifyMyMultiFactorU2F(base64, this.name)
|
||||
// .then(() => {
|
||||
// this.translate
|
||||
// .get("USER.MFA.U2F_SUCCESS")
|
||||
// .pipe(take(1))
|
||||
// .subscribe((msg) => {
|
||||
// this.toast.showInfo(msg);
|
||||
// });
|
||||
// this.dialogRef.close(true);
|
||||
// this.loading = false;
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// this.loading = false;
|
||||
// this.toast.showError(error);
|
||||
// });
|
||||
// } else if (this.type === U2FComponentDestination.PASSWORDLESS) {
|
||||
// this.service
|
||||
// .verifyMyPasswordless(base64, this.name)
|
||||
// .then(() => {
|
||||
// this.translate
|
||||
// .get("USER.PASSWORDLESS.U2F_SUCCESS")
|
||||
// .pipe(take(1))
|
||||
// .subscribe((msg) => {
|
||||
// this.toast.showInfo(msg);
|
||||
// });
|
||||
// this.dialogRef.close(true);
|
||||
// this.loading = false;
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// this.loading = false;
|
||||
// this.toast.showError(error);
|
||||
// });
|
||||
// }
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError("An error on registering passkey");
|
||||
|
||||
161
apps/login/ui/SetPasswordForm.tsx
Normal file
161
apps/login/ui/SetPasswordForm.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
LegalAndSupportSettings,
|
||||
PasswordComplexitySettings,
|
||||
} from "@zitadel/server";
|
||||
import PasswordComplexity from "./PasswordComplexity";
|
||||
import { useState } from "react";
|
||||
import { Button, ButtonVariants } from "./Button";
|
||||
import { TextInput } from "./Input";
|
||||
import { PrivacyPolicyCheckboxes } from "./PrivacyPolicyCheckboxes";
|
||||
import { FieldValues, useForm } from "react-hook-form";
|
||||
import {
|
||||
lowerCaseValidator,
|
||||
numberValidator,
|
||||
symbolValidator,
|
||||
upperCaseValidator,
|
||||
} from "#/utils/validators";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Spinner } from "./Spinner";
|
||||
|
||||
type Inputs =
|
||||
| {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
| FieldValues;
|
||||
|
||||
type Props = {
|
||||
passwordComplexitySettings: PasswordComplexitySettings;
|
||||
email: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
};
|
||||
|
||||
export default function SetPasswordForm({
|
||||
passwordComplexitySettings,
|
||||
email,
|
||||
firstname,
|
||||
lastname,
|
||||
}: Props) {
|
||||
const { register, handleSubmit, watch, formState } = useForm<Inputs>({
|
||||
mode: "onBlur",
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
async function submitRegister(values: Inputs) {
|
||||
setLoading(true);
|
||||
const res = await fetch("/registeruser", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: email,
|
||||
firstName: firstname,
|
||||
lastName: lastname,
|
||||
password: values.password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setLoading(false);
|
||||
throw new Error("Failed to register user");
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function submitAndLink(value: Inputs): Promise<boolean | void> {
|
||||
return submitRegister(value).then((resp: any) => {
|
||||
return router.push(`/verify?userID=${resp.userId}`);
|
||||
});
|
||||
}
|
||||
|
||||
const { errors } = formState;
|
||||
|
||||
const watchPassword = watch("password", "");
|
||||
const watchConfirmPassword = watch("confirmPassword", "");
|
||||
|
||||
const [tosAndPolicyAccepted, setTosAndPolicyAccepted] = useState(false);
|
||||
|
||||
const hasMinLength =
|
||||
passwordComplexitySettings &&
|
||||
watchPassword?.length >= passwordComplexitySettings.minLength;
|
||||
const hasSymbol = symbolValidator(watchPassword);
|
||||
const hasNumber = numberValidator(watchPassword);
|
||||
const hasUppercase = upperCaseValidator(watchPassword);
|
||||
const hasLowercase = lowerCaseValidator(watchPassword);
|
||||
|
||||
const policyIsValid =
|
||||
passwordComplexitySettings &&
|
||||
(passwordComplexitySettings.requiresLowercase ? hasLowercase : true) &&
|
||||
(passwordComplexitySettings.requiresNumber ? hasNumber : true) &&
|
||||
(passwordComplexitySettings.requiresUppercase ? hasUppercase : true) &&
|
||||
(passwordComplexitySettings.requiresSymbol ? hasSymbol : true) &&
|
||||
hasMinLength;
|
||||
|
||||
return (
|
||||
<form className="w-full">
|
||||
<div className="pt-4 grid grid-cols-1 gap-4 mb-4">
|
||||
<div className="">
|
||||
<TextInput
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
{...register("password", {
|
||||
required: "You have to provide a password!",
|
||||
})}
|
||||
label="Password"
|
||||
error={errors.password?.message as string}
|
||||
/>
|
||||
</div>
|
||||
<div className="">
|
||||
<TextInput
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
{...register("confirmPassword", {
|
||||
required: "This field is required",
|
||||
})}
|
||||
label="Confirm Password"
|
||||
error={errors.confirmPassword?.message as string}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{passwordComplexitySettings && (
|
||||
<PasswordComplexity
|
||||
passwordComplexitySettings={passwordComplexitySettings}
|
||||
password={watchPassword}
|
||||
equals={!!watchPassword && watchPassword === watchConfirmPassword}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex w-full flex-row items-center justify-between">
|
||||
<Button type="button" variant={ButtonVariants.Secondary}>
|
||||
back
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={ButtonVariants.Primary}
|
||||
disabled={
|
||||
loading ||
|
||||
!policyIsValid ||
|
||||
!formState.isValid ||
|
||||
watchPassword !== watchConfirmPassword
|
||||
}
|
||||
onClick={handleSubmit(submitAndLink)}
|
||||
>
|
||||
{loading && <Spinner className="h-5 w-5 mr-2" />}
|
||||
continue
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user