tests, session using timestamp

This commit is contained in:
peintnermax
2024-10-23 14:28:33 +02:00
parent cde5f6cbd0
commit 52ce9219bb
21 changed files with 232 additions and 116 deletions

View File

@@ -1,10 +1,11 @@
import { stub } from "../support/mock"; import { stub } from "../support/mock";
describe("/verify", () => { describe("/verify", () => {
it("redirects after successful email verification", () => { it("shows password and passkey method after successful invite verification", () => {
stub("zitadel.user.v2.UserService", "VerifyEmail"); stub("zitadel.user.v2.UserService", "VerifyEmail");
cy.visit("/verify?userId=123&code=abc&submit=true"); cy.visit("/verify?userId=123&code=abc&submit=true&invite=true");
cy.location("pathname", { timeout: 10_000 }).should("eq", "/loginname"); cy.contains("Password");
cy.contains("Passkey");
}); });
it("shows an error if validation failed", () => { it("shows an error if validation failed", () => {
stub("zitadel.user.v2.UserService", "VerifyEmail", { stub("zitadel.user.v2.UserService", "VerifyEmail", {
@@ -14,6 +15,6 @@ describe("/verify", () => {
// TODO: Avoid uncaught exception in application // TODO: Avoid uncaught exception in application
cy.once("uncaught:exception", () => false); cy.once("uncaught:exception", () => false);
cy.visit("/verify?userId=123&code=abc&submit=true"); cy.visit("/verify?userId=123&code=abc&submit=true");
cy.contains("Could not verify email"); cy.contains("Could not verify user");
}); });
}); });

View File

@@ -139,6 +139,7 @@
"title": "Benutzer einladen", "title": "Benutzer einladen",
"description": "Geben Sie die E-Mail-Adresse des Benutzers ein, den Sie einladen möchten.", "description": "Geben Sie die E-Mail-Adresse des Benutzers ein, den Sie einladen möchten.",
"info": "Der Benutzer erhält eine E-Mail mit einem Link, um sich zu registrieren.", "info": "Der Benutzer erhält eine E-Mail mit einem Link, um sich zu registrieren.",
"notAllowed": "Sie haben keine Berechtigung, Benutzer einzuladen.",
"submit": "Einladen", "submit": "Einladen",
"success": { "success": {
"title": "Einladung erfolgreich", "title": "Einladung erfolgreich",
@@ -153,11 +154,17 @@
"description": "Sie sind angemeldet." "description": "Sie sind angemeldet."
}, },
"verify": { "verify": {
"title": "Benutzer verifizieren",
"description": "Geben Sie den Code ein, der in der Bestätigungs-E-Mail angegeben ist.",
"userIdMissing": "Keine Benutzer-ID angegeben!", "userIdMissing": "Keine Benutzer-ID angegeben!",
"resendCode": "Code erneut senden", "verify": {
"submit": "Weiter" "title": "Benutzer verifizieren",
"description": "Geben Sie den Code ein, der in der Bestätigungs-E-Mail angegeben ist.",
"resendCode": "Code erneut senden",
"submit": "Weiter"
},
"setup": {
"title": "Authentifizierungsmethode auswählen",
"description": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten."
}
}, },
"error": { "error": {
"unknownContext": "Der Kontext des Benutzers konnte nicht ermittelt werden. Stellen Sie sicher, dass Sie zuerst den Benutzernamen eingeben oder einen loginName als Suchparameter angeben.", "unknownContext": "Der Kontext des Benutzers konnte nicht ermittelt werden. Stellen Sie sicher, dass Sie zuerst den Benutzernamen eingeben oder einen loginName als Suchparameter angeben.",

View File

@@ -139,6 +139,7 @@
"title": "Invite User", "title": "Invite User",
"description": "Provide the email address and the name of the user you want to invite.", "description": "Provide the email address and the name of the user you want to invite.",
"info": "The user will receive an email with further instructions.", "info": "The user will receive an email with further instructions.",
"notAllowed": "Your settings do not allow you to invite users.",
"submit": "Continue", "submit": "Continue",
"success": { "success": {
"title": "User invited", "title": "User invited",
@@ -153,11 +154,17 @@
"description": "You are signed in." "description": "You are signed in."
}, },
"verify": { "verify": {
"title": "Verify user",
"description": "Enter the Code provided in the verification email.",
"userIdMissing": "No userId provided!", "userIdMissing": "No userId provided!",
"resendCode": "Resend code", "verify": {
"submit": "Continue" "title": "Verify user",
"description": "Enter the Code provided in the verification email.",
"resendCode": "Resend code",
"submit": "Continue"
},
"setup": {
"title": "Choose authentication method",
"description": "Select the method you would like to authenticate"
}
}, },
"error": { "error": {
"unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.", "unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.",

View File

@@ -139,6 +139,7 @@
"title": "Invitar usuario", "title": "Invitar usuario",
"description": "Introduce el correo electrónico del usuario que deseas invitar.", "description": "Introduce el correo electrónico del usuario que deseas invitar.",
"info": "El usuario recibirá un correo electrónico con un enlace para completar el registro.", "info": "El usuario recibirá un correo electrónico con un enlace para completar el registro.",
"notAllowed": "No tienes permiso para invitar usuarios.",
"submit": "Invitar usuario", "submit": "Invitar usuario",
"success": { "success": {
"title": "¡Usuario invitado!", "title": "¡Usuario invitado!",
@@ -153,11 +154,17 @@
"description": "Has iniciado sesión." "description": "Has iniciado sesión."
}, },
"verify": { "verify": {
"title": "Verificar usuario",
"description": "Introduce el código proporcionado en el correo electrónico de verificación.",
"userIdMissing": "¡No se proporcionó userId!", "userIdMissing": "¡No se proporcionó userId!",
"resendCode": "Reenviar código", "verify": {
"submit": "Continuar" "title": "Verificar usuario",
"description": "Introduce el código proporcionado en el correo electrónico de verificación.",
"resendCode": "Reenviar código",
"submit": "Continuar"
},
"setup": {
"title": "Seleccionar método de autenticación",
"description": "Selecciona el método con el que deseas autenticarte"
}
}, },
"error": { "error": {
"unknownContext": "No se pudo obtener el contexto del usuario. Asegúrate de ingresar primero el nombre de usuario o proporcionar un loginName como parámetro de búsqueda.", "unknownContext": "No se pudo obtener el contexto del usuario. Asegúrate de ingresar primero el nombre de usuario o proporcionar un loginName como parámetro de búsqueda.",

View File

@@ -139,6 +139,7 @@
"title": "Invita Utente", "title": "Invita Utente",
"description": "Inserisci l'indirizzo email dell'utente che desideri invitare.", "description": "Inserisci l'indirizzo email dell'utente che desideri invitare.",
"info": "L'utente riceverà un'email con ulteriori istruzioni.", "info": "L'utente riceverà un'email con ulteriori istruzioni.",
"notAllowed": "Non hai i permessi per invitare un utente.",
"submit": "Invita Utente", "submit": "Invita Utente",
"success": { "success": {
"title": "Invito inviato", "title": "Invito inviato",
@@ -153,11 +154,17 @@
"description": "Sei connesso." "description": "Sei connesso."
}, },
"verify": { "verify": {
"title": "Verifica utente",
"description": "Inserisci il codice fornito nell'email di verifica.",
"userIdMissing": "Nessun userId fornito!", "userIdMissing": "Nessun userId fornito!",
"resendCode": "Invia di nuovo il codice", "verify": {
"submit": "Continua" "title": "Verifica utente",
"description": "Inserisci il codice fornito nell'email di verifica.",
"resendCode": "Invia di nuovo il codice",
"submit": "Continua"
},
"setup": {
"title": "Seleziona metodo di autenticazione",
"description": "Seleziona il metodo con cui desideri autenticarti"
}
}, },
"error": { "error": {
"unknownContext": "Impossibile ottenere il contesto dell'utente. Assicurati di inserire prima il nome utente o di fornire un loginName come parametro di ricerca.", "unknownContext": "Impossibile ottenere il contesto dell'utente. Assicurati di inserire prima il nome utente o di fornire un loginName come parametro di ricerca.",

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 467 467" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> <svg width="100%" height="100%" viewBox="0 0 467 467" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;strokeLinejoin:round;stroke-miterlimit:2;">
<g id="zitadel-logo-solo-darkdesign" transform="matrix(0.564847,0,0,0.659318,-1282.85,0)"> <g id="zitadel-logo-solo-darkdesign" transform="matrix(0.564847,0,0,0.659318,-1282.85,0)">
<rect x="2271.15" y="0" width="826.773" height="708.241" style="fill:none;"/> <rect x="2271.15" y="0" width="826.773" height="708.241" style="fill:none;"/>
<g transform="matrix(4.96737,-1.14029,1.331,4.25561,-5923.46,-2258.26)"> <g transform="matrix(4.96737,-1.14029,1.331,4.25561,-5923.46,-2258.26)">

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 467 468" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> <svg width="100%" height="100%" viewBox="0 0 467 468" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;strokeLinejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,0,-492)"> <g transform="matrix(1,0,0,1,0,-492)">
<g id="zitadel-logo-solo-lightdesign" transform="matrix(0.564847,0,0,0.659318,-1282.85,492.925)"> <g id="zitadel-logo-solo-lightdesign" transform="matrix(0.564847,0,0,0.659318,-1282.85,492.925)">
<rect x="2271.15" y="0" width="826.773" height="708.241" style="fill:none;"/> <rect x="2271.15" y="0" width="826.773" height="708.241" style="fill:none;"/>

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 295 81" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> <svg width="100%" height="100%" viewBox="0 0 295 81" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;strokeLinejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,0,-107)"> <g transform="matrix(1,0,0,1,0,-107)">
<g id="zitadel-logo-dark" transform="matrix(1,0,0,1,-20.9181,18.2562)"> <g id="zitadel-logo-dark" transform="matrix(1,0,0,1,-20.9181,18.2562)">
<rect x="20.918" y="89.57" width="294.943" height="79.632" style="fill:none;"/> <rect x="20.918" y="89.57" width="294.943" height="79.632" style="fill:none;"/>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 295 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> <svg width="100%" height="100%" viewBox="0 0 295 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;strokeLinejoin:round;stroke-miterlimit:2;">
<g id="zitadel-logo-light" transform="matrix(1,0,0,1,-20.9181,-89.5699)"> <g id="zitadel-logo-light" transform="matrix(1,0,0,1,-20.9181,-89.5699)">
<rect x="20.918" y="89.57" width="294.943" height="79.632" style="fill:none;"/> <rect x="20.918" y="89.57" width="294.943" height="79.632" style="fill:none;"/>
<g transform="matrix(2.73883,0,0,1.55076,-35267,23.6366)"> <g transform="matrix(2.73883,0,0,1.55076,-35267,23.6366)">

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -4,6 +4,7 @@ import { InviteForm } from "@/components/invite-form";
import { import {
getBrandingSettings, getBrandingSettings,
getDefaultOrg, getDefaultOrg,
getLoginSettings,
getPasswordComplexitySettings, getPasswordComplexitySettings,
} from "@/lib/zitadel"; } from "@/lib/zitadel";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
@@ -27,6 +28,8 @@ export default async function Page({
organization = org.id; organization = org.id;
} }
const loginSettings = await getLoginSettings(organization);
const passwordComplexitySettings = const passwordComplexitySettings =
await getPasswordComplexitySettings(organization); await getPasswordComplexitySettings(organization);
@@ -38,9 +41,13 @@ export default async function Page({
<h1>{t("title")}</h1> <h1>{t("title")}</h1>
<p className="ztdl-p">{t("description")}</p> <p className="ztdl-p">{t("description")}</p>
<Alert type={AlertType.INFO}>{t("info")}</Alert> {!loginSettings?.allowRegister ? (
<Alert type={AlertType.ALERT}>{t("notAllowed")}</Alert>
) : (
<Alert type={AlertType.INFO}>{t("info")}</Alert>
)}
{passwordComplexitySettings && ( {passwordComplexitySettings && loginSettings?.allowRegister && (
<InviteForm <InviteForm
organization={organization} organization={organization}
firstname={firstname} firstname={firstname}

View File

@@ -1,6 +1,11 @@
import { Alert } from "@/components/alert";
import { AuthenticatorMethods } from "@/components/authenticator-methods";
import { DynamicTheme } from "@/components/dynamic-theme"; import { DynamicTheme } from "@/components/dynamic-theme";
import { VerifyEmailForm } from "@/components/verify-email-form"; import { UserAvatar } from "@/components/user-avatar";
import { getBrandingSettings, getLoginSettings } from "@/lib/zitadel"; import { VerifyForm } from "@/components/verify-form";
import { verifyUser } from "@/lib/server/email";
import { getBrandingSettings, getUserByID } from "@/lib/zitadel";
import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
export default async function Page({ searchParams }: { searchParams: any }) { export default async function Page({ searchParams }: { searchParams: any }) {
@@ -20,21 +25,86 @@ export default async function Page({ searchParams }: { searchParams: any }) {
const branding = await getBrandingSettings(organization); const branding = await getBrandingSettings(organization);
const loginSettings = await getLoginSettings(organization); let verifyResponse, error;
if (code && userId) {
verifyResponse = await verifyUser({
code,
userId,
isInvite: invite === "true",
}).catch(() => {
error = "Could not verify user";
});
}
let user: User | undefined;
let human: HumanUser | undefined;
if (userId) {
const userResponse = await getUserByID(userId);
if (userResponse) {
user = userResponse.user;
if (user?.type.case === "human") {
human = user.type.value as HumanUser;
}
}
}
const params = new URLSearchParams({
userId: userId,
initial: "true", // defines that a code is not required and is therefore not shown in the UI
});
if (organization) {
params.set("organization", organization);
}
if (authRequestId) {
params.set("authRequest", authRequestId);
}
if (sessionId) {
params.set("sessionId", sessionId);
}
return ( return (
<DynamicTheme branding={branding}> <DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4"> <div className="flex flex-col items-center space-y-4">
<VerifyEmailForm {!userId && (
userId={userId} <>
loginName={loginName} <h1>{t("verify.title")}</h1>
code={code} <p className="ztdl-p mb-6 block">{t("verify.description")}</p>
organization={organization}
authRequestId={authRequestId} <div className="py-4">
sessionId={sessionId} <Alert>{tError("unknownContext")}</Alert>
loginSettings={loginSettings} </div>
isInvite={invite === "true"} </>
/> )}
{!verifyResponse || !verifyResponse.authMethodTypes ? (
<VerifyForm
userId={userId}
loginName={loginName}
code={!error ? code : ""}
organization={organization}
authRequestId={authRequestId}
sessionId={sessionId}
isInvite={invite === "true"}
/>
) : (
<>
<h1>{t("setup.title")}</h1>
<p className="ztdl-p mb-6 block">{t("setup.description")}</p>
{user && (
<UserAvatar
loginName={user.preferredLoginName}
displayName={human?.profile?.displayName}
showDropdown={false}
/>
)}
<AuthenticatorMethods
authMethods={verifyResponse.authMethodTypes}
params={params}
/>
</>
)}
</div> </div>
</DynamicTheme> </DynamicTheme>
); );

View File

@@ -139,7 +139,7 @@ export const EMAIL = (alreadyAdded: boolean, link: string) => {
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width={1.5} strokeWidth={1.5}
stroke="currentColor" stroke="currentColor"
> >
<path <path
@@ -174,12 +174,12 @@ export const SMS = (alreadyAdded: boolean, link: string) => {
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" strokeWidth="1.5"
stroke="currentColor" stroke="currentColor"
> >
<path <path
stroke-linecap="round" strokeLinecap="round"
stroke-linejoin="round" strokeLinejoin="round"
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
/> />
</svg> </svg>
@@ -207,13 +207,13 @@ export const PASSKEYS = (alreadyAdded: boolean, link: string) => {
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" strokeWidth="1.5"
stroke="currentColor" stroke="currentColor"
className="w-8 h-8 mr-4" className="w-8 h-8 mr-4"
> >
<path <path
stroke-linecap="round" strokeLinecap="round"
stroke-linejoin="round" strokeLinejoin="round"
d="M7.864 4.243A7.5 7.5 0 0119.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 004.5 10.5a7.464 7.464 0 01-1.15 3.993m1.989 3.559A11.209 11.209 0 008.25 10.5a3.75 3.75 0 117.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 01-3.6 9.75m6.633-4.596a18.666 18.666 0 01-2.485 5.33" d="M7.864 4.243A7.5 7.5 0 0119.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 004.5 10.5a7.464 7.464 0 01-1.15 3.993m1.989 3.559A11.209 11.209 0 008.25 10.5a3.75 3.75 0 117.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 01-3.6 9.75m6.633-4.596a18.666 18.666 0 01-2.485 5.33"
/> />
</svg> </svg>

View File

@@ -0,0 +1,18 @@
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { PASSKEYS, PASSWORD } from "./auth-methods";
type Props = {
authMethods: AuthenticationMethodType[];
params: URLSearchParams;
};
export function AuthenticatorMethods({ authMethods, params }: Props) {
return (
<div className="grid grid-cols-1 gap-5 w-full pt-4">
{!authMethods.includes(AuthenticationMethodType.PASSWORD) &&
PASSWORD(false, "/password/set?" + params)}
{!authMethods.includes(AuthenticationMethodType.PASSKEY) &&
PASSKEYS(false, "/passkeys/set?" + params)}
</div>
);
}

View File

@@ -77,6 +77,8 @@ export function ChangePasswordForm({
return; return;
} }
await new Promise((resolve) => setTimeout(resolve, 1000)); // wait for a second, to prevent eventual consistency issues
const passwordResponse = await sendPassword({ const passwordResponse = await sendPassword({
loginName, loginName,
organization, organization,

View File

@@ -59,7 +59,7 @@ export function SetPasswordForm({
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
async function submitRegister(values: Inputs) { async function submitPassword(values: Inputs) {
setLoading(true); setLoading(true);
let payload: { userId: string; password: string; code?: string } = { let payload: { userId: string; password: string; code?: string } = {
userId: userId, userId: userId,
@@ -73,16 +73,19 @@ export function SetPasswordForm({
const changeResponse = await changePassword(payload).catch(() => { const changeResponse = await changePassword(payload).catch(() => {
setError("Could not set password"); setError("Could not set password");
setLoading(false);
return;
}); });
if (changeResponse && "error" in changeResponse) {
setError(changeResponse.error);
}
setLoading(false); setLoading(false);
if (changeResponse && "error" in changeResponse) {
setError(changeResponse.error);
return;
}
if (!changeResponse) { if (!changeResponse) {
setError("Could not register user"); setError("Could not set password");
return; return;
} }
@@ -95,6 +98,8 @@ export function SetPasswordForm({
params.append("organization", organization); params.append("organization", organization);
} }
await new Promise((resolve) => setTimeout(resolve, 2000)); // Wait for a second to avoid eventual consistency issues with an initial password being set
const passwordResponse = await sendPassword({ const passwordResponse = await sendPassword({
loginName, loginName,
organization, organization,
@@ -213,7 +218,7 @@ export function SetPasswordForm({
!formState.isValid || !formState.isValid ||
watchPassword !== watchConfirmPassword watchPassword !== watchConfirmPassword
} }
onClick={handleSubmit(submitRegister)} onClick={handleSubmit(submitPassword)}
> >
{loading && <Spinner className="h-5 w-5 mr-2" />} {loading && <Spinner className="h-5 w-5 mr-2" />}
{t("set.submit")} {t("set.submit")}

View File

@@ -2,13 +2,12 @@
import { Alert } from "@/components/alert"; import { Alert } from "@/components/alert";
import { resendVerification, verifyUser } from "@/lib/server/email"; import { resendVerification, verifyUser } from "@/lib/server/email";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { PASSKEYS, PASSWORD } from "./auth-methods"; import { AuthenticatorMethods } from "./authenticator-methods";
import { Button, ButtonVariants } from "./button"; import { Button, ButtonVariants } from "./button";
import { TextInput } from "./input"; import { TextInput } from "./input";
import { Spinner } from "./spinner"; import { Spinner } from "./spinner";
@@ -18,25 +17,25 @@ type Inputs = {
}; };
type Props = { type Props = {
userId?: string; userId: string;
loginName: string; loginName: string;
code: string; code: string;
organization?: string; organization?: string;
authRequestId?: string; authRequestId?: string;
sessionId?: string; sessionId?: string;
loginSettings?: LoginSettings;
isInvite: boolean; isInvite: boolean;
verifyError?: string;
}; };
export function VerifyEmailForm({ export function VerifyForm({
userId, userId,
loginName, loginName,
code, code,
organization, organization,
authRequestId, authRequestId,
sessionId, sessionId,
loginSettings,
isInvite, isInvite,
verifyError,
}: Props) { }: Props) {
const t = useTranslations("verify"); const t = useTranslations("verify");
const tError = useTranslations("error"); const tError = useTranslations("error");
@@ -52,30 +51,19 @@ export function VerifyEmailForm({
AuthenticationMethodType[] | null AuthenticationMethodType[] | null
>(null); >(null);
useEffect(() => { const [error, setError] = useState<string>(verifyError || "");
if (code && userId) {
// When we navigate to this page, we always want to be redirected if submit is true and the parameters are valid.
// For programmatic verification, the /verifyemail API should be used.
submitCodeAndContinue({ code });
}
}, []);
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const router = useRouter(); const router = useRouter();
const params = new URLSearchParams({}); const params = new URLSearchParams({
userId: userId,
if (userId) { });
params.append("userId", userId);
}
if (isInvite) { if (isInvite) {
params.append("initial", "true"); params.append("initial", "true");
} }
if (loginName) { if (loginName) {
params.append("loginName", loginName); params.append("loginName", loginName);
} }
@@ -131,13 +119,10 @@ export function VerifyEmailForm({
// if auth methods fall trough, we complete to login // if auth methods fall trough, we complete to login
const params = new URLSearchParams({ const params = new URLSearchParams({
userId: userId,
initial: "true", // defines that a code is not required and is therefore not shown in the UI initial: "true", // defines that a code is not required and is therefore not shown in the UI
}); });
if (userId) {
params.set("userId", userId);
}
if (organization) { if (organization) {
params.set("organization", organization); params.set("organization", organization);
} }
@@ -153,14 +138,8 @@ export function VerifyEmailForm({
return !authMethods ? ( return !authMethods ? (
<> <>
<h1>{t("title")}</h1> <h1>{t("verify.title")}</h1>
<p className="ztdl-p mb-6 block">{t("description")}</p> <p className="ztdl-p mb-6 block">{t("verify.description")}</p>
{!userId && (
<div className="py-4">
<Alert>{tError("unknownContext")}</Alert>
</div>
)}
<form className="w-full"> <form className="w-full">
<div className=""> <div className="">
@@ -169,7 +148,6 @@ export function VerifyEmailForm({
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"
// error={errors.username?.message as string}
/> />
</div> </div>
@@ -185,7 +163,7 @@ export function VerifyEmailForm({
onClick={() => resendCode()} onClick={() => resendCode()}
variant={ButtonVariants.Secondary} variant={ButtonVariants.Secondary}
> >
{t("resendCode")} {t("verify.resendCode")}
</Button> </Button>
<span className="flex-grow"></span> <span className="flex-grow"></span>
<Button <Button
@@ -196,22 +174,17 @@ export function VerifyEmailForm({
onClick={handleSubmit(submitCodeAndContinue)} onClick={handleSubmit(submitCodeAndContinue)}
> >
{loading && <Spinner className="h-5 w-5 mr-2" />} {loading && <Spinner className="h-5 w-5 mr-2" />}
{t("submit")} {t("verify.submit")}
</Button> </Button>
</div> </div>
</form> </form>
</> </>
) : ( ) : (
<> <>
<h1>{t("title")}</h1> <h1>{t("setup.title")}</h1>
<p className="ztdl-p mb-6 block">{t("description")}</p> <p className="ztdl-p mb-6 block">{t("setup.description")}</p>
<div className="grid grid-cols-1 gap-5 w-full pt-4"> <AuthenticatorMethods authMethods={authMethods} params={params} />
{!authMethods.includes(AuthenticationMethodType.PASSWORD) &&
PASSWORD(false, "/password/set?" + params)}
{!authMethods.includes(AuthenticationMethodType.PASSKEY) &&
PASSKEYS(false, "/passkeys/set?" + params)}
</div>
</> </>
); );
} }

View File

@@ -7,7 +7,7 @@ import {
getSession, getSession,
setSession, setSession,
} from "@/lib/zitadel"; } from "@/lib/zitadel";
import { timestampDate } from "@zitadel/client"; import { timestampMs } from "@zitadel/client";
import { import {
Challenges, Challenges,
RequestChallenges, RequestChallenges,
@@ -43,13 +43,13 @@ export async function createSessionAndUpdateCookie(
id: createdSession.sessionId, id: createdSession.sessionId,
token: createdSession.sessionToken, token: createdSession.sessionToken,
creationDate: response.session.creationDate creationDate: response.session.creationDate
? `${timestampDate(response.session.creationDate).toDateString()}` ? `${timestampMs(response.session.creationDate)}`
: "", : "",
expirationDate: response.session.expirationDate expirationDate: response.session.expirationDate
? `${timestampDate(response.session.expirationDate).toDateString()}` ? `${timestampMs(response.session.expirationDate)}`
: "", : "",
changeDate: response.session.changeDate changeDate: response.session.changeDate
? `${timestampDate(response.session.changeDate).toDateString()}` ? `${timestampMs(response.session.changeDate)}`
: "", : "",
loginName: response.session.factors.user.loginName ?? "", loginName: response.session.factors.user.loginName ?? "",
}; };
@@ -98,13 +98,13 @@ export async function createSessionForIdpAndUpdateCookie(
id: createdSession.sessionId, id: createdSession.sessionId,
token: createdSession.sessionToken, token: createdSession.sessionToken,
creationDate: response.session.creationDate creationDate: response.session.creationDate
? `${timestampDate(response.session.creationDate).toDateString()}` ? `${timestampMs(response.session.creationDate)}`
: "", : "",
expirationDate: response.session.expirationDate expirationDate: response.session.expirationDate
? `${timestampDate(response.session.expirationDate).toDateString()}` ? `${timestampMs(response.session.expirationDate)}`
: "", : "",
changeDate: response.session.changeDate changeDate: response.session.changeDate
? `${timestampDate(response.session.changeDate).toDateString()}` ? `${timestampMs(response.session.changeDate)}`
: "", : "",
loginName: response.session.factors.user.loginName ?? "", loginName: response.session.factors.user.loginName ?? "",
organization: response.session.factors.user.organizationId ?? "", organization: response.session.factors.user.organizationId ?? "",
@@ -155,7 +155,7 @@ export async function setSessionAndUpdateCookie(
expirationDate: recentCookie.expirationDate, expirationDate: recentCookie.expirationDate,
// just overwrite the changeDate with the new one // just overwrite the changeDate with the new one
changeDate: updatedSession.details?.changeDate changeDate: updatedSession.details?.changeDate
? `${timestampDate(updatedSession.details.changeDate).toDateString()}` ? `${timestampMs(updatedSession.details.changeDate)}`
: "", : "",
loginName: recentCookie.loginName, loginName: recentCookie.loginName,
organization: recentCookie.organization, organization: recentCookie.organization,
@@ -178,7 +178,7 @@ export async function setSessionAndUpdateCookie(
expirationDate: sessionCookie.expirationDate, expirationDate: sessionCookie.expirationDate,
// just overwrite the changeDate with the new one // just overwrite the changeDate with the new one
changeDate: updatedSession.details?.changeDate changeDate: updatedSession.details?.changeDate
? `${timestampDate(updatedSession.details.changeDate).toDateString()}` ? `${timestampMs(updatedSession.details.changeDate)}`
: "", : "",
loginName: session.factors?.user?.loginName ?? "", loginName: session.factors?.user?.loginName ?? "",
organization: session.factors?.user?.organizationId ?? "", organization: session.factors?.user?.organizationId ?? "",

View File

@@ -139,6 +139,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
if (users.result[0].state === UserState.INITIAL) { if (users.result[0].state === UserState.INITIAL) {
const params = new URLSearchParams({ const params = new URLSearchParams({
loginName: session.factors?.user?.loginName, loginName: session.factors?.user?.loginName,
initial: "true", // this does not require a code to be set
}); });
if (command.organization || session.factors?.user?.organizationId) { if (command.organization || session.factors?.user?.organizationId) {

View File

@@ -284,5 +284,5 @@ export async function changePassword(command: {
} }
const userId = user.userId; const userId = user.userId;
return setPassword(userId, command.password, command.code); return setPassword(userId, command.password, user, command.code);
} }

View File

@@ -37,7 +37,11 @@ import {
SearchQuery, SearchQuery,
SearchQuerySchema, SearchQuerySchema,
} from "@zitadel/proto/zitadel/user/v2/query_pb"; } from "@zitadel/proto/zitadel/user/v2/query_pb";
import { SendInviteCodeSchema } from "@zitadel/proto/zitadel/user/v2/user_pb"; import {
SendInviteCodeSchema,
User,
UserState,
} from "@zitadel/proto/zitadel/user/v2/user_pb";
import { unstable_cache } from "next/cache"; import { unstable_cache } from "next/cache";
import { PROVIDER_MAPPING } from "./idp"; import { PROVIDER_MAPPING } from "./idp";
@@ -327,7 +331,7 @@ export async function createInviteCode(userId: string, host: string | null) {
if (host) { if (host) {
medium = { medium = {
...medium, ...medium,
urlTemplate: `https://${host}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true`, urlTemplate: `${host.includes("localhost") ? "http://" : "https://"}${host}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true`,
}; };
} }
@@ -564,7 +568,7 @@ export async function passwordReset(userId: string, host: string | null) {
if (host) { if (host) {
medium = { medium = {
...medium, ...medium,
urlTemplate: `https://${host}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}`, urlTemplate: `${host.includes("localhost") ? "http://" : "https://"}${host}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}`,
}; };
} }
@@ -590,6 +594,7 @@ export async function passwordReset(userId: string, host: string | null) {
export async function setPassword( export async function setPassword(
userId: string, userId: string,
password: string, password: string,
user: User,
code?: string, code?: string,
) { ) {
let payload = create(SetPasswordRequestSchema, { let payload = create(SetPasswordRequestSchema, {
@@ -605,9 +610,8 @@ export async function setPassword(
// if the user has no authmethods set, we can set a password otherwise we need a code // if the user has no authmethods set, we can set a password otherwise we need a code
if ( if (
!authmethods || !(authmethods.authMethodTypes.length === 0) &&
!authmethods.authMethodTypes || user.state !== UserState.INITIAL
authmethods.authMethodTypes.length === 0
) { ) {
return { error: "Provide a code to set a password" }; return { error: "Provide a code to set a password" };
} }
@@ -623,7 +627,14 @@ export async function setPassword(
}; };
} }
return userService.setPassword(payload, {}); return userService.setPassword(payload, {}).catch((error) => {
// throw error if failed precondition (ex. User is not yet initialized)
if (error.code === 9 && error.message) {
return { error: error.message };
} else {
throw error;
}
});
} }
/** /**

View File

@@ -3,5 +3,5 @@ export { NewAuthorizationBearerInterceptor } from "./interceptors";
// TODO: Move this to `./protobuf.ts` and export it from there // TODO: Move this to `./protobuf.ts` and export it from there
export { create, fromJson, toJson } from "@bufbuild/protobuf"; export { create, fromJson, toJson } from "@bufbuild/protobuf";
export { TimestampSchema, timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt"; export { TimestampSchema, timestampDate, timestampFromDate, timestampMs } from "@bufbuild/protobuf/wkt";
export type { Timestamp } from "@bufbuild/protobuf/wkt"; export type { Timestamp } from "@bufbuild/protobuf/wkt";