Merge pull request #338 from zitadel/qa

promote qa to prod
This commit is contained in:
Elio Bischof
2025-01-14 01:14:48 +01:00
committed by GitHub
20 changed files with 245 additions and 34 deletions

View File

@@ -40,6 +40,7 @@ echo "ZITADEL_API_URL=${ZITADEL_API_URL}
ZITADEL_SERVICE_USER_ID=${ZITADEL_SERVICE_USER_ID} ZITADEL_SERVICE_USER_ID=${ZITADEL_SERVICE_USER_ID}
ZITADEL_SERVICE_USER_TOKEN=${PAT} ZITADEL_SERVICE_USER_TOKEN=${PAT}
SINK_NOTIFICATION_URL=${SINK_NOTIFICATION_URL} SINK_NOTIFICATION_URL=${SINK_NOTIFICATION_URL}
EMAIL_VERIFICATION=true
DEBUG=true"| tee "${WRITE_ENVIRONMENT_FILE}" "${WRITE_TEST_ENVIRONMENT_FILE}" > /dev/null DEBUG=true"| tee "${WRITE_ENVIRONMENT_FILE}" "${WRITE_TEST_ENVIRONMENT_FILE}" > /dev/null
echo "Wrote environment file ${WRITE_ENVIRONMENT_FILE}" echo "Wrote environment file ${WRITE_ENVIRONMENT_FILE}"
cat ${WRITE_ENVIRONMENT_FILE} cat ${WRITE_ENVIRONMENT_FILE}

View File

@@ -0,0 +1,12 @@
import { expect, Page } from "@playwright/test";
const codeTextInput = "code-text-input";
export async function emailVerifyScreen(page: Page, code: string) {
await page.getByTestId(codeTextInput).pressSequentially(code);
}
export async function emailVerifyScreenExpect(page: Page, code: string) {
await expect(page.getByTestId(codeTextInput)).toHaveValue(code);
await expect(page.getByTestId("error").locator("div")).toContainText("Could not verify email");
}

View File

@@ -0,0 +1,73 @@
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { emailVerify, emailVerifyResend } from "./email-verify";
import { emailVerifyScreenExpect } from "./email-verify-screen";
import { loginScreenExpect, loginWithPassword } from "./login";
import { getCodeFromSink } from "./sink";
import { PasswordUser } from "./user";
// Read from ".env" file.
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasswordUser }>({
user: async ({ page }, use) => {
const user = new PasswordUser({
email: faker.internet.email(),
isEmailVerified: false,
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number(),
isPhoneVerified: false,
password: "Password1!",
passwordChangeRequired: false,
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});
test("user email not verified, verify", async ({ user, page }) => {
await loginWithPassword(page, user.getUsername(), user.getPassword());
// auto-redirect on /verify
// wait for send of the code
await page.waitForTimeout(3000);
const c = await getCodeFromSink(user.getUsername());
await emailVerify(page, c);
await loginScreenExpect(page, user.getFullName());
});
test("user email not verified, resend, verify", async ({ user, page }) => {
await loginWithPassword(page, user.getUsername(), user.getPassword());
// auto-redirect on /verify
await emailVerifyResend(page);
// wait for send of the code
await page.waitForTimeout(3000);
const c = await getCodeFromSink(user.getUsername());
await emailVerify(page, c);
await loginScreenExpect(page, user.getFullName());
});
test("user email not verified, resend, old code", async ({ user, page }) => {
await loginWithPassword(page, user.getUsername(), user.getPassword());
// auto-redirect on /verify
// wait for send of the code
await page.waitForTimeout(3000);
const c = await getCodeFromSink(user.getUsername());
await emailVerifyResend(page);
// wait for resend of the code
await page.waitForTimeout(1000);
await emailVerify(page, c);
await emailVerifyScreenExpect(page, c);
});
test("user email not verified, wrong code", async ({ user, page }) => {
await loginWithPassword(page, user.getUsername(), user.getPassword());
// auto-redirect on /verify
const code = "wrong";
await emailVerify(page, code);
await emailVerifyScreenExpect(page, code);
});

View File

@@ -0,0 +1,15 @@
import { Page } from "@playwright/test";
import { emailVerifyScreen } from "./email-verify-screen";
export async function startEmailVerify(page: Page, loginname: string) {
await page.goto("/verify");
}
export async function emailVerify(page: Page, code: string) {
await emailVerifyScreen(page, code);
await page.getByTestId("submit-button").click();
}
export async function emailVerifyResend(page: Page) {
await page.getByTestId("resend-button").click();
}

View File

@@ -4,6 +4,10 @@ import { getCodeFromSink } from "./sink";
const codeField = "code-text-input"; const codeField = "code-text-input";
const passwordField = "password-text-input"; const passwordField = "password-text-input";
const passwordConfirmField = "password-confirm-text-input"; const passwordConfirmField = "password-confirm-text-input";
const passwordChangeField = "password-change-text-input";
const passwordChangeConfirmField = "password-change-confirm-text-input";
const passwordSetField = "password-set-text-input";
const passwordSetConfirmField = "password-set-confirm-text-input";
const lengthCheck = "length-check"; const lengthCheck = "length-check";
const symbolCheck = "symbol-check"; const symbolCheck = "symbol-check";
const numberCheck = "number-check"; const numberCheck = "number-check";
@@ -15,8 +19,8 @@ const matchText = "Matches";
const noMatchText = "Doesn't match"; const noMatchText = "Doesn't match";
export async function changePasswordScreen(page: Page, password1: string, password2: string) { export async function changePasswordScreen(page: Page, password1: string, password2: string) {
await page.getByTestId(passwordField).pressSequentially(password1); await page.getByTestId(passwordChangeField).pressSequentially(password1);
await page.getByTestId(passwordConfirmField).pressSequentially(password2); await page.getByTestId(passwordChangeConfirmField).pressSequentially(password2);
} }
export async function passwordScreen(page: Page, password: string) { export async function passwordScreen(page: Page, password: string) {
@@ -39,9 +43,21 @@ export async function changePasswordScreenExpect(
lowercase: boolean, lowercase: boolean,
equals: boolean, equals: boolean,
) { ) {
await expect(page.getByTestId(passwordField)).toHaveValue(password1); await expect(page.getByTestId(passwordChangeField)).toHaveValue(password1);
await expect(page.getByTestId(passwordConfirmField)).toHaveValue(password2); await expect(page.getByTestId(passwordChangeConfirmField)).toHaveValue(password2);
await checkComplexity(page, length, symbol, number, uppercase, lowercase, equals);
}
async function checkComplexity(
page: Page,
length: boolean,
symbol: boolean,
number: boolean,
uppercase: boolean,
lowercase: boolean,
equals: boolean,
) {
await checkContent(page, lengthCheck, length); await checkContent(page, lengthCheck, length);
await checkContent(page, symbolCheck, symbol); await checkContent(page, symbolCheck, symbol);
await checkContent(page, numberCheck, number); await checkContent(page, numberCheck, number);
@@ -63,8 +79,8 @@ export async function resetPasswordScreen(page: Page, username: string, password
await page.waitForTimeout(3000); await page.waitForTimeout(3000);
const c = await getCodeFromSink(username); const c = await getCodeFromSink(username);
await page.getByTestId(codeField).pressSequentially(c); await page.getByTestId(codeField).pressSequentially(c);
await page.getByTestId(passwordField).pressSequentially(password1); await page.getByTestId(passwordSetField).pressSequentially(password1);
await page.getByTestId(passwordConfirmField).pressSequentially(password2); await page.getByTestId(passwordSetConfirmField).pressSequentially(password2);
} }
export async function resetPasswordScreenExpect( export async function resetPasswordScreenExpect(
@@ -78,5 +94,8 @@ export async function resetPasswordScreenExpect(
lowercase: boolean, lowercase: boolean,
equals: boolean, equals: boolean,
) { ) {
await changePasswordScreenExpect(page, password1, password2, length, symbol, number, uppercase, lowercase, equals); await expect(page.getByTestId(passwordSetField)).toHaveValue(password1);
await expect(page.getByTestId(passwordSetConfirmField)).toHaveValue(password2);
await checkComplexity(page, length, symbol, number, uppercase, lowercase, equals);
} }

View File

@@ -8,8 +8,7 @@ export async function startChangePassword(page: Page, loginname: string) {
await page.goto("/password/change?" + new URLSearchParams({ loginName: loginname })); await page.goto("/password/change?" + new URLSearchParams({ loginName: loginname }));
} }
export async function changePassword(page: Page, loginname: string, password: string) { export async function changePassword(page: Page, password: string) {
await startChangePassword(page, loginname);
await changePasswordScreen(page, password, password); await changePasswordScreen(page, password, password);
await page.getByTestId(passwordSubmitButton).click(); await page.getByTestId(passwordSubmitButton).click();
} }

View File

@@ -1,6 +1,8 @@
import { Page } from "@playwright/test"; import { Page } from "@playwright/test";
import { emailVerify } from "./email-verify";
import { passkeyRegister } from "./passkey"; import { passkeyRegister } from "./passkey";
import { registerPasswordScreen, registerUserScreenPasskey, registerUserScreenPassword } from "./register-screen"; import { registerPasswordScreen, registerUserScreenPasskey, registerUserScreenPassword } from "./register-screen";
import { getCodeFromSink } from "./sink";
export async function registerWithPassword( export async function registerWithPassword(
page: Page, page: Page,
@@ -15,6 +17,9 @@ export async function registerWithPassword(
await page.getByTestId("submit-button").click(); await page.getByTestId("submit-button").click();
await registerPasswordScreen(page, password1, password2); await registerPasswordScreen(page, password1, password2);
await page.getByTestId("submit-button").click(); await page.getByTestId("submit-button").click();
await page.waitForTimeout(3000);
await verifyEmail(page, email);
} }
export async function registerWithPasskey(page: Page, firstname: string, lastname: string, email: string): Promise<string> { export async function registerWithPasskey(page: Page, firstname: string, lastname: string, email: string): Promise<string> {
@@ -23,7 +28,15 @@ export async function registerWithPasskey(page: Page, firstname: string, lastnam
await page.getByTestId("submit-button").click(); await page.getByTestId("submit-button").click();
// wait for projection of user // wait for projection of user
await page.waitForTimeout(2000); await page.waitForTimeout(3000);
const authId = await passkeyRegister(page);
return await passkeyRegister(page); await verifyEmail(page, email);
return authId;
}
async function verifyEmail(page: Page, email: string) {
await page.waitForTimeout(1000);
const c = await getCodeFromSink(email);
await emailVerify(page, c);
} }

View File

@@ -4,11 +4,14 @@ import { activateOTP, addTOTP, addUser, getUserByUsername, removeUser } from "./
export interface userProps { export interface userProps {
email: string; email: string;
isEmailVerified?: boolean;
firstName: string; firstName: string;
lastName: string; lastName: string;
organization: string; organization: string;
password: string; password: string;
passwordChangeRequired?: boolean;
phone: string; phone: string;
isPhoneVerified?: boolean;
} }
class User { class User {
@@ -77,11 +80,14 @@ export enum OtpType {
export interface otpUserProps { export interface otpUserProps {
email: string; email: string;
isEmailVerified?: boolean;
firstName: string; firstName: string;
lastName: string; lastName: string;
organization: string; organization: string;
password: string; password: string;
passwordChangeRequired?: boolean;
phone: string; phone: string;
isPhoneVerified?: boolean;
type: OtpType; type: OtpType;
} }
@@ -96,6 +102,9 @@ export class PasswordUserWithOTP extends User {
organization: props.organization, organization: props.organization,
password: props.password, password: props.password,
phone: props.phone, phone: props.phone,
isEmailVerified: props.isEmailVerified,
isPhoneVerified: props.isPhoneVerified,
passwordChangeRequired: props.passwordChangeRequired,
}); });
this.type = props.type; this.type = props.type;
} }
@@ -133,6 +142,8 @@ export interface passkeyUserProps {
lastName: string; lastName: string;
organization: string; organization: string;
phone: string; phone: string;
isEmailVerified?: boolean;
isPhoneVerified?: boolean;
} }
export class PasskeyUser extends User { export class PasskeyUser extends User {
@@ -146,6 +157,8 @@ export class PasskeyUser extends User {
organization: props.organization, organization: props.organization,
password: "", password: "",
phone: props.phone, phone: props.phone,
isEmailVerified: props.isEmailVerified,
isPhoneVerified: props.isPhoneVerified,
}); });
} }

View File

@@ -12,10 +12,12 @@ const test = base.extend<{ user: PasskeyUser }>({
user: async ({ page }, use) => { user: async ({ page }, use) => {
const user = new PasskeyUser({ const user = new PasskeyUser({
email: faker.internet.email(), email: faker.internet.email(),
isEmailVerified: true,
firstName: faker.person.firstName(), firstName: faker.person.firstName(),
lastName: faker.person.lastName(), lastName: faker.person.lastName(),
organization: "", organization: "",
phone: faker.phone.number(), phone: faker.phone.number(),
isPhoneVerified: false,
}); });
await user.ensure(page); await user.ensure(page);
await use(user); await use(user);

View File

@@ -0,0 +1,41 @@
import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test";
import dotenv from "dotenv";
import path from "path";
import { loginScreenExpect, loginWithPassword } from "./login";
import { changePassword } from "./password";
import { PasswordUser } from "./user";
// Read from ".env" file.
dotenv.config({ path: path.resolve(__dirname, ".env.local") });
const test = base.extend<{ user: PasswordUser }>({
user: async ({ page }, use) => {
const user = new PasswordUser({
email: faker.internet.email(),
isEmailVerified: true,
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
organization: "",
phone: faker.phone.number(),
isPhoneVerified: false,
password: "Password1!",
passwordChangeRequired: true,
});
await user.ensure(page);
await use(user);
await user.cleanup();
},
});
test("username and password login, change required", async ({ user, page }) => {
const changedPw = "ChangedPw1!";
await loginWithPassword(page, user.getUsername(), user.getPassword());
await page.waitForTimeout(100);
await changePassword(page, changedPw);
await loginScreenExpect(page, user.getFullName());
await loginWithPassword(page, user.getUsername(), changedPw);
await loginScreenExpect(page, user.getFullName());
});

View File

@@ -2,8 +2,8 @@ import { faker } from "@faker-js/faker";
import { test as base } from "@playwright/test"; import { test as base } from "@playwright/test";
import dotenv from "dotenv"; import dotenv from "dotenv";
import path from "path"; import path from "path";
import { loginWithPassword } from "./login"; import { loginScreenExpect, loginWithPassword } from "./login";
import { startChangePassword } from "./password"; import { changePassword, startChangePassword } from "./password";
import { changePasswordScreen, changePasswordScreenExpect } from "./password-screen"; import { changePasswordScreen, changePasswordScreenExpect } from "./password-screen";
import { PasswordUser } from "./user"; import { PasswordUser } from "./user";
@@ -14,11 +14,14 @@ const test = base.extend<{ user: PasswordUser }>({
user: async ({ page }, use) => { user: async ({ page }, use) => {
const user = new PasswordUser({ const user = new PasswordUser({
email: faker.internet.email(), email: faker.internet.email(),
isEmailVerified: true,
firstName: faker.person.firstName(), firstName: faker.person.firstName(),
lastName: faker.person.lastName(), lastName: faker.person.lastName(),
organization: "", organization: "",
phone: faker.phone.number(), phone: faker.phone.number(),
isPhoneVerified: false,
password: "Password1!", password: "Password1!",
passwordChangeRequired: false,
}); });
await user.ensure(page); await user.ensure(page);
await use(user); await use(user);
@@ -27,20 +30,18 @@ const test = base.extend<{ user: PasswordUser }>({
}); });
test("username and password changed login", async ({ user, page }) => { test("username and password changed login", async ({ user, page }) => {
// commented, fix in https://github.com/zitadel/zitadel/pull/8807 const changedPw = "ChangedPw1!";
/* await loginWithPassword(page, user.getUsername(), user.getPassword());
const changedPw = "ChangedPw1!";
await loginWithPassword(page, user.getUsername(), user.getPassword());
// wait for projection of token // wait for projection of token
await page.waitForTimeout(2000); await page.waitForTimeout(2000);
await changePassword(page, user.getUsername(), changedPw); await startChangePassword(page, user.getUsername());
await loginScreenExpect(page, user.getFullName()); await changePassword(page, changedPw);
await loginScreenExpect(page, user.getFullName());
await loginWithPassword(page, user.getUsername(), changedPw); await loginWithPassword(page, user.getUsername(), changedPw);
await loginScreenExpect(page, user.getFullName()); await loginScreenExpect(page, user.getFullName());
*/
}); });
test("password change not with desired complexity", async ({ user, page }) => { test("password change not with desired complexity", async ({ user, page }) => {

View File

@@ -14,11 +14,14 @@ const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({
user: async ({ page }, use) => { user: async ({ page }, use) => {
const user = new PasswordUserWithOTP({ const user = new PasswordUserWithOTP({
email: faker.internet.email(), email: faker.internet.email(),
isEmailVerified: true,
firstName: faker.person.firstName(), firstName: faker.person.firstName(),
lastName: faker.person.lastName(), lastName: faker.person.lastName(),
organization: "", organization: "",
phone: faker.phone.number(), phone: faker.phone.number(),
isPhoneVerified: false,
password: "Password1!", password: "Password1!",
passwordChangeRequired: false,
type: OtpType.email, type: OtpType.email,
}); });

View File

@@ -14,11 +14,14 @@ const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({
user: async ({ page }, use) => { user: async ({ page }, use) => {
const user = new PasswordUserWithOTP({ const user = new PasswordUserWithOTP({
email: faker.internet.email(), email: faker.internet.email(),
isEmailVerified: true,
firstName: faker.person.firstName(), firstName: faker.person.firstName(),
lastName: faker.person.lastName(), lastName: faker.person.lastName(),
organization: "", organization: "",
phone: faker.phone.number({ style: "international" }), phone: faker.phone.number({ style: "international" }),
isPhoneVerified: true,
password: "Password1!", password: "Password1!",
passwordChangeRequired: false,
type: OtpType.sms, type: OtpType.sms,
}); });

View File

@@ -15,11 +15,14 @@ const test = base.extend<{ user: PasswordUser }>({
user: async ({ page }, use) => { user: async ({ page }, use) => {
const user = new PasswordUser({ const user = new PasswordUser({
email: faker.internet.email(), email: faker.internet.email(),
isEmailVerified: true,
firstName: faker.person.firstName(), firstName: faker.person.firstName(),
lastName: faker.person.lastName(), lastName: faker.person.lastName(),
organization: "", organization: "",
phone: faker.phone.number(), phone: faker.phone.number(),
isPhoneVerified: false,
password: "Password1!", password: "Password1!",
passwordChangeRequired: false,
}); });
await user.ensure(page); await user.ensure(page);
await use(user); await use(user);
@@ -28,8 +31,6 @@ const test = base.extend<{ user: PasswordUser }>({
}); });
test("username and password set login", async ({ user, page }) => { test("username and password set login", async ({ user, page }) => {
// commented, fix in https://github.com/zitadel/zitadel/pull/8807
const changedPw = "ChangedPw1!"; const changedPw = "ChangedPw1!";
await startLogin(page); await startLogin(page);
await loginname(page, user.getUsername()); await loginname(page, user.getUsername());

View File

@@ -14,11 +14,14 @@ const test = base.extend<{ user: PasswordUserWithTOTP; sink: any }>({
user: async ({ page }, use) => { user: async ({ page }, use) => {
const user = new PasswordUserWithTOTP({ const user = new PasswordUserWithTOTP({
email: faker.internet.email(), email: faker.internet.email(),
isEmailVerified: true,
firstName: faker.person.firstName(), firstName: faker.person.firstName(),
lastName: faker.person.lastName(), lastName: faker.person.lastName(),
organization: "", organization: "",
phone: faker.phone.number({ style: "international" }), phone: faker.phone.number({ style: "international" }),
isPhoneVerified: true,
password: "Password1!", password: "Password1!",
passwordChangeRequired: false,
}); });
await user.ensure(page); await user.ensure(page);

View File

@@ -16,11 +16,14 @@ const test = base.extend<{ user: PasswordUser }>({
user: async ({ page }, use) => { user: async ({ page }, use) => {
const user = new PasswordUser({ const user = new PasswordUser({
email: faker.internet.email(), email: faker.internet.email(),
isEmailVerified: true,
firstName: faker.person.firstName(), firstName: faker.person.firstName(),
lastName: faker.person.lastName(), lastName: faker.person.lastName(),
organization: "", organization: "",
phone: faker.phone.number(), phone: faker.phone.number(),
isPhoneVerified: false,
password: "Password1!", password: "Password1!",
passwordChangeRequired: false,
}); });
await user.ensure(page); await user.ensure(page);
await use(user); await use(user);

View File

@@ -19,13 +19,20 @@ export async function addUser(props: userProps) {
isVerified: true, isVerified: true,
}, },
phone: { phone: {
phone: props.phone!, phone: props.phone,
isVerified: true, isVerified: true,
}, },
password: { password: {
password: props.password!, password: props.password,
changeRequired: props.passwordChangeRequired ?? false,
}, },
}; };
if (!props.isEmailVerified) {
delete body.email.isVerified;
}
if (!props.isPhoneVerified) {
delete body.phone.isVerified;
}
return await listCall(`${process.env.ZITADEL_API_URL}/v2/users/human`, body); return await listCall(`${process.env.ZITADEL_API_URL}/v2/users/human`, body);
} }

View File

@@ -161,7 +161,7 @@ export function ChangePasswordForm({
})} })}
label="New Password" label="New Password"
error={errors.password?.message as string} error={errors.password?.message as string}
data-testid="password-text-input" data-testid="password-change-text-input"
/> />
</div> </div>
<div className=""> <div className="">
@@ -174,7 +174,7 @@ export function ChangePasswordForm({
})} })}
label="Confirm Password" label="Confirm Password"
error={errors.confirmPassword?.message as string} error={errors.confirmPassword?.message as string}
data-testid="password-confirm-text-input" data-testid="password-change-confirm-text-input"
/> />
</div> </div>
</div> </div>

View File

@@ -237,7 +237,7 @@ export function SetPasswordForm({
})} })}
label="New Password" label="New Password"
error={errors.password?.message as string} error={errors.password?.message as string}
data-testid="password-text-input" data-testid="password-set-text-input"
/> />
</div> </div>
<div> <div>
@@ -250,7 +250,7 @@ export function SetPasswordForm({
})} })}
label="Confirm Password" label="Confirm Password"
error={errors.confirmPassword?.message as string} error={errors.confirmPassword?.message as string}
data-testid="password-confirm-text-input" data-testid="password-set-confirm-text-input"
/> />
</div> </div>
</div> </div>

View File

@@ -115,7 +115,7 @@ export function VerifyForm({
{t("verify.noCodeReceived")} {t("verify.noCodeReceived")}
</span> </span>
<button <button
aria-label="Resend OTP Code" aria-label="Resend Code"
disabled={loading} disabled={loading}
type="button" type="button"
className="ml-4 text-primary-light-500 dark:text-primary-dark-500 hover:dark:text-primary-dark-400 hover:text-primary-light-400 cursor-pointer disabled:cursor-default disabled:text-gray-400 dark:disabled:text-gray-700" className="ml-4 text-primary-light-500 dark:text-primary-dark-500 hover:dark:text-primary-dark-400 hover:text-primary-light-400 cursor-pointer disabled:cursor-default disabled:text-gray-400 dark:disabled:text-gray-700"
@@ -134,11 +134,12 @@ export function VerifyForm({
autoComplete="one-time-code" autoComplete="one-time-code"
{...register("code", { required: "This field is required" })} {...register("code", { required: "This field is required" })}
label="Code" label="Code"
data-testid="code-text-input"
/> />
</div> </div>
{error && ( {error && (
<div className="py-4"> <div className="py-4" data-testid="error">
<Alert>{error}</Alert> <Alert>{error}</Alert>
</div> </div>
)} )}
@@ -152,6 +153,7 @@ export function VerifyForm({
variant={ButtonVariants.Primary} variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid} disabled={loading || !formState.isValid}
onClick={handleSubmit(fcn)} onClick={handleSubmit(fcn)}
data-testid="submit-button"
> >
{loading && <Spinner className="h-5 w-5 mr-2" />} {loading && <Spinner className="h-5 w-5 mr-2" />}
{t("verify.submit")} {t("verify.submit")}