Merge pull request #233 from zitadel/invite-users

feat: user invite links
This commit is contained in:
Max Peintner
2024-10-25 10:17:39 +02:00
committed by GitHub
44 changed files with 1346 additions and 373 deletions

View File

@@ -1 +1,2 @@
custom-config.js
custom-config.js
.env.local

View File

@@ -5,11 +5,18 @@ describe("register", () => {
stub("zitadel.user.v2.UserService", "AddHumanUser", {
data: {
userId: "123",
email: {
email: "john@zitadel.com",
},
profile: {
givenName: "John",
familyName: "Doe",
},
},
});
});
it("should redirect a user who selects passwordless on register to /passkeys/add", () => {
it("should redirect a user who selects passwordless on register to /passkey/set", () => {
cy.visit("/register");
cy.get('input[autocomplete="firstname"]').focus().type("John");
cy.get('input[autocomplete="lastname"]').focus().type("Doe");

View File

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

View File

@@ -135,16 +135,40 @@
"submit": "Weiter"
}
},
"invite": {
"title": "Benutzer einladen",
"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.",
"notAllowed": "Sie haben keine Berechtigung, Benutzer einzuladen.",
"submit": "Einladen",
"success": {
"title": "Einladung erfolgreich",
"description": "Der Benutzer wurde erfolgreich eingeladen.",
"verified": "Der Benutzer wurde eingeladen und hat seine E-Mail bereits verifiziert.",
"notVerifiedYet": "Der Benutzer wurde eingeladen. Er erhält eine E-Mail mit weiteren Anweisungen.",
"submit": "Weiteren Benutzer einladen"
}
},
"signedin": {
"title": "Willkommen {user}!",
"description": "Sie sind angemeldet."
},
"verify": {
"title": "Benutzer verifizieren",
"description": "Geben Sie den Code ein, der in der Bestätigungs-E-Mail angegeben ist.",
"userIdMissing": "Keine Benutzer-ID angegeben!",
"resendCode": "Code erneut senden",
"submit": "Weiter"
"success": "Erfolgreich verifiziert",
"setupAuthenticator": "Authentifikator einrichten",
"verify": {
"title": "Benutzer verifizieren",
"description": "Geben Sie den Code ein, der in der Bestätigungs-E-Mail angegeben ist.",
"resendCode": "Code erneut senden",
"submit": "Weiter"
}
},
"authenticator": {
"title": "Authentifizierungsmethode auswählen",
"description": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten.",
"noMethodsAvailable": "Keine Authentifizierungsmethoden verfügbar",
"allSetup": "Sie haben bereits einen Authentifikator eingerichtet!"
},
"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.",

View File

@@ -135,16 +135,40 @@
"submit": "Continue"
}
},
"invite": {
"title": "Invite User",
"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.",
"notAllowed": "Your settings do not allow you to invite users.",
"submit": "Continue",
"success": {
"title": "User invited",
"description": "The email has successfully been sent.",
"verified": "The user has been invited and has already verified his email.",
"notVerifiedYet": "The user has been invited. They will receive an email with further instructions.",
"submit": "Invite another user"
}
},
"signedin": {
"title": "Welcome {user}!",
"description": "You are signed in."
},
"verify": {
"title": "Verify user",
"description": "Enter the Code provided in the verification email.",
"userIdMissing": "No userId provided!",
"resendCode": "Resend code",
"submit": "Continue"
"success": "The user has been verified successfully.",
"setupAuthenticator": "Setup authenticator",
"verify": {
"title": "Verify user",
"description": "Enter the Code provided in the verification email.",
"resendCode": "Resend code",
"submit": "Continue"
}
},
"authenticator": {
"title": "Choose authentication method",
"description": "Select the method you would like to authenticate",
"noMethodsAvailable": "No authentication methods available",
"allSetup": "You have already setup an authenticator!"
},
"error": {
"unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.",

View File

@@ -135,16 +135,40 @@
"submit": "Continuar"
}
},
"invite": {
"title": "Invitar usuario",
"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.",
"notAllowed": "No tienes permiso para invitar usuarios.",
"submit": "Invitar usuario",
"success": {
"title": "¡Usuario invitado!",
"description": "El usuario ha sido invitado.",
"verified": "El usuario ha sido invitado y ya ha verificado su correo electrónico.",
"notVerifiedYet": "El usuario ha sido invitado. Recibirá un correo electrónico con más instrucciones.",
"submit": "Invitar a otro usuario"
}
},
"signedin": {
"title": "¡Bienvenido {user}!",
"description": "Has iniciado sesión."
},
"verify": {
"title": "Verificar usuario",
"description": "Introduce el código proporcionado en el correo electrónico de verificación.",
"userIdMissing": "¡No se proporcionó userId!",
"resendCode": "Reenviar código",
"submit": "Continuar"
"success": "¡Verificación exitosa!",
"setupAuthenticator": "Configurar autenticador",
"verify": {
"title": "Verificar usuario",
"description": "Introduce el código proporcionado en el correo electrónico de verificación.",
"resendCode": "Reenviar código",
"submit": "Continuar"
}
},
"authenticator": {
"title": "Seleccionar método de autenticación",
"description": "Selecciona el método con el que deseas autenticarte",
"noMethodsAvailable": "No hay métodos de autenticación disponibles",
"allSetup": "¡Ya has configurado un autenticador!"
},
"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.",

View File

@@ -135,16 +135,40 @@
"submit": "Continua"
}
},
"invite": {
"title": "Invita Utente",
"description": "Inserisci l'indirizzo email dell'utente che desideri invitare.",
"info": "L'utente riceverà un'email con ulteriori istruzioni.",
"notAllowed": "Non hai i permessi per invitare un utente.",
"submit": "Invita Utente",
"success": {
"title": "Invito inviato",
"description": "L'utente è stato invitato con successo.",
"verified": "L'utente è stato invitato e ha già verificato la sua email.",
"notVerifiedYet": "L'utente è stato invitato. Riceverà un'email con ulteriori istruzioni.",
"submit": "Invita un altro utente"
}
},
"signedin": {
"title": "Benvenuto {user}!",
"description": "Sei connesso."
},
"verify": {
"title": "Verifica utente",
"description": "Inserisci il codice fornito nell'email di verifica.",
"userIdMissing": "Nessun userId fornito!",
"resendCode": "Invia di nuovo il codice",
"submit": "Continua"
"success": "Verifica effettuata con successo!",
"setupAuthenticator": "Configura autenticatore",
"verify": {
"title": "Verifica utente",
"description": "Inserisci il codice fornito nell'email di verifica.",
"resendCode": "Invia di nuovo il codice",
"submit": "Continua"
}
},
"authenticator": {
"title": "Seleziona metodo di autenticazione",
"description": "Seleziona il metodo con cui desideri autenticarti",
"noMethodsAvailable": "Nessun metodo di autenticazione disponibile",
"allSetup": "Hai già configurato un autenticatore!"
},
"error": {
"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"?>
<!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)">
<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)">

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"?>
<!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 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;"/>

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"?>
<!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 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;"/>

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"?>
<!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)">
<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)">

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,135 @@
import { Alert } from "@/components/alert";
import { BackButton } from "@/components/back-button";
import { ChooseAuthenticatorToSetup } from "@/components/choose-authenticator-to-setup";
import { DynamicTheme } from "@/components/dynamic-theme";
import { UserAvatar } from "@/components/user-avatar";
import { getSessionCookieById } from "@/lib/cookies";
import { loadMostRecentSession } from "@/lib/session";
import {
getBrandingSettings,
getLoginSettings,
getSession,
getUserByID,
listAuthenticationMethodTypes,
} from "@/lib/zitadel";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { getLocale, getTranslations } from "next-intl/server";
export default async function Page({
searchParams,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "authenticator" });
const tError = await getTranslations({ locale, namespace: "error" });
const { loginName, authRequestId, organization, sessionId } = searchParams;
const sessionWithData = sessionId
? await loadSessionById(sessionId, organization)
: await loadSessionByLoginname(loginName, organization);
async function getAuthMethodsAndUser(session?: Session) {
const userId = session?.factors?.user?.id;
if (!userId) {
throw Error("Could not get user id from session");
}
return listAuthenticationMethodTypes(userId).then((methods) => {
return getUserByID(userId).then((user) => {
const humanUser =
user.user?.type.case === "human" ? user.user?.type.value : undefined;
return {
factors: session?.factors,
authMethods: methods.authMethodTypes ?? [],
phoneVerified: humanUser?.phone?.isVerified ?? false,
emailVerified: humanUser?.email?.isVerified ?? false,
expirationDate: session?.expirationDate,
};
});
});
}
async function loadSessionByLoginname(
loginName?: string,
organization?: string,
) {
return loadMostRecentSession({
loginName,
organization,
}).then((session) => {
return getAuthMethodsAndUser(session);
});
}
async function loadSessionById(sessionId: string, organization?: string) {
const recent = await getSessionCookieById({ sessionId, organization });
return getSession({
sessionId: recent.id,
sessionToken: recent.token,
}).then((sessionResponse) => {
return getAuthMethodsAndUser(sessionResponse.session);
});
}
const branding = await getBrandingSettings(
sessionWithData.factors?.user?.organizationId,
);
const loginSettings = await getLoginSettings(
sessionWithData.factors?.user?.organizationId,
);
const params = new URLSearchParams({
initial: "true", // defines that a code is not required and is therefore not shown in the UI
});
if (loginName) {
params.set("loginName", loginName);
}
if (organization) {
params.set("organization", organization);
}
if (authRequestId) {
params.set("authRequestId", authRequestId);
}
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{t("title")}</h1>
<p className="ztdl-p">{t("description")}</p>
{sessionWithData && (
<UserAvatar
loginName={loginName ?? sessionWithData.factors?.user?.loginName}
displayName={sessionWithData.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
)}
{!(loginName || sessionId) && <Alert>{tError("unknownContext")}</Alert>}
{loginSettings && sessionWithData && (
<ChooseAuthenticatorToSetup
authMethods={sessionWithData.authMethods}
loginSettings={loginSettings}
params={params}
></ChooseAuthenticatorToSetup>
)}
<div className="mt-8 flex w-full flex-row items-center">
<BackButton />
<span className="flex-grow"></span>
</div>
</div>
</DynamicTheme>
);
}

View File

@@ -0,0 +1,61 @@
import { Alert, AlertType } from "@/components/alert";
import { DynamicTheme } from "@/components/dynamic-theme";
import { InviteForm } from "@/components/invite-form";
import {
getBrandingSettings,
getDefaultOrg,
getLoginSettings,
getPasswordComplexitySettings,
} from "@/lib/zitadel";
import { getLocale, getTranslations } from "next-intl/server";
export default async function Page({
searchParams,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "invite" });
let { firstname, lastname, email, organization } = searchParams;
if (!organization) {
const org = await getDefaultOrg();
if (!org) {
throw new Error("No default organization found");
}
organization = org.id;
}
const loginSettings = await getLoginSettings(organization);
const passwordComplexitySettings =
await getPasswordComplexitySettings(organization);
const branding = await getBrandingSettings(organization);
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{t("title")}</h1>
<p className="ztdl-p">{t("description")}</p>
{!loginSettings?.allowRegister ? (
<Alert type={AlertType.ALERT}>{t("notAllowed")}</Alert>
) : (
<Alert type={AlertType.INFO}>{t("info")}</Alert>
)}
{passwordComplexitySettings && loginSettings?.allowRegister && (
<InviteForm
organization={organization}
firstname={firstname}
lastname={lastname}
email={email}
></InviteForm>
)}
</div>
</DynamicTheme>
);
}

View File

@@ -0,0 +1,71 @@
import { Alert, AlertType } from "@/components/alert";
import { Button, ButtonVariants } from "@/components/button";
import { DynamicTheme } from "@/components/dynamic-theme";
import { UserAvatar } from "@/components/user-avatar";
import { getBrandingSettings, getDefaultOrg, getUserByID } from "@/lib/zitadel";
import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { getLocale, getTranslations } from "next-intl/server";
import Link from "next/link";
export default async function Page({
searchParams,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "invite" });
let { userId, organization } = searchParams;
if (!organization) {
const org = await getDefaultOrg();
if (!org) {
throw new Error("No default organization found");
}
organization = org.id;
}
const branding = await getBrandingSettings(organization);
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;
}
}
}
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{t("success.title")}</h1>
<p className="ztdl-p">{t("success.description")}</p>
{user && (
<UserAvatar
loginName={user.preferredLoginName}
displayName={human?.profile?.displayName}
showDropdown={false}
/>
)}
{human?.email?.isVerified ? (
<Alert type={AlertType.INFO}>{t("success.verified")}</Alert>
) : (
<Alert type={AlertType.INFO}>{t("success.notVerifiedYet")}</Alert>
)}
<div className="mt-8 flex w-full flex-row items-center justify-between">
<span></span>
<Link href="/invite">
<Button type="submit" variant={ButtonVariants.Primary}>
{t("success.submit")}
</Button>
</Link>
</div>
</div>
</DynamicTheme>
);
}

View File

@@ -35,16 +35,13 @@ export default async function RootLayout({
<ThemeProvider>
<NextIntlClientProvider messages={messages}>
<div
className={`h-screen overflow-y-scroll bg-background-light-600 dark:bg-background-dark-600`}
className={`relative min-h-screen bg-background-light-600 dark:bg-background-dark-600 flex flex-col justify-center`}
>
<div className="absolute bottom-0 right-0 flex flex-row p-4 items-center space-x-4">
<LanguageSwitcher />
<Theme />
</div>
<div className={`pb-4 flex flex-col justify-center h-full`}>
<div className="mx-auto max-w-[440px] space-y-8 pt-20 lg:py-8 w-full">
{children}
<div className="relative mx-auto max-w-[440px] py-8 w-full ">
{children}
<div className="flex flex-row justify-end py-4 items-center space-x-4">
<LanguageSwitcher />
<Theme />
</div>
</div>
</div>

View File

@@ -15,7 +15,8 @@ export default async function Page({
const t = await getTranslations({ locale, namespace: "passkey" });
const tError = await getTranslations({ locale, namespace: "error" });
const { loginName, prompt, organization, authRequestId } = searchParams;
const { loginName, prompt, organization, authRequestId, userId } =
searchParams;
const session = await loadMostRecentSession({
loginName,

View File

@@ -19,7 +19,7 @@ export default async function Page({
const t = await getTranslations({ locale, namespace: "password" });
const tError = await getTranslations({ locale, namespace: "error" });
const { loginName, organization, authRequestId, code } = searchParams;
const { loginName, organization, authRequestId } = searchParams;
// also allow no session to be found (ignoreUnkownUsername)
const sessionFactors = await loadMostRecentSession({

View File

@@ -22,7 +22,8 @@ export default async function Page({
const t = await getTranslations({ locale, namespace: "password" });
const tError = await getTranslations({ locale, namespace: "error" });
const { userId, loginName, organization, authRequestId, code } = searchParams;
const { userId, loginName, organization, authRequestId, code, initial } =
searchParams;
// also allow no session to be found (ignoreUnkownUsername)
let session: Session | undefined;
@@ -81,7 +82,7 @@ export default async function Page({
></UserAvatar>
) : null}
<Alert type={AlertType.INFO}>{t("set.codeSent")}</Alert>
{!initial && <Alert type={AlertType.INFO}>{t("set.codeSent")}</Alert>}
{passwordComplexity &&
(loginName ?? user?.preferredLoginName) &&
@@ -93,6 +94,7 @@ export default async function Page({
authRequestId={authRequestId}
organization={organization}
passwordComplexitySettings={passwordComplexity}
codeRequired={!(initial === "true")}
/>
) : (
<div className="py-4">

View File

@@ -3,6 +3,7 @@ import { RegisterFormWithoutPassword } from "@/components/register-form-without-
import { SetRegisterPasswordForm } from "@/components/set-register-password-form";
import {
getBrandingSettings,
getDefaultOrg,
getLegalAndSupportSettings,
getPasswordComplexitySettings,
} from "@/lib/zitadel";
@@ -16,11 +17,16 @@ export default async function Page({
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "register" });
const { firstname, lastname, email, organization, authRequestId } =
let { firstname, lastname, email, organization, authRequestId } =
searchParams;
if (!organization) {
// TODO: get default organization
const org = await getDefaultOrg();
if (!org) {
throw new Error("No default organization found");
}
organization = org.id;
}
const setPassword = !!(firstname && lastname && email);

View File

@@ -1,57 +1,118 @@
import { Alert } from "@/components/alert";
import { Alert, AlertType } from "@/components/alert";
import { BackButton } from "@/components/back-button";
import { Button, ButtonVariants } from "@/components/button";
import { DynamicTheme } from "@/components/dynamic-theme";
import { VerifyEmailForm } from "@/components/verify-email-form";
import { getBrandingSettings, getLoginSettings } from "@/lib/zitadel";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { UserAvatar } from "@/components/user-avatar";
import { VerifyForm } from "@/components/verify-form";
import {
getBrandingSettings,
getUserByID,
listAuthenticationMethodTypes,
} from "@/lib/zitadel";
import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { getLocale, getTranslations } from "next-intl/server";
import Link from "next/link";
export default async function Page({ searchParams }: { searchParams: any }) {
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "verify" });
const tError = await getTranslations({ locale, namespace: "error" });
const {
userId,
loginName,
sessionId,
code,
submit,
organization,
authRequestId,
} = searchParams;
const { userId, loginName, code, organization, authRequestId, invite } =
searchParams;
const branding = await getBrandingSettings(organization);
const loginSettings = await getLoginSettings(organization);
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;
}
}
}
let authMethods: AuthenticationMethodType[] | null = null;
if (human?.email?.isVerified) {
const authMethodsResponse = await listAuthenticationMethodTypes(userId);
if (authMethodsResponse.authMethodTypes) {
authMethods = authMethodsResponse.authMethodTypes;
}
}
const params = new URLSearchParams({
userId: userId,
initial: "true", // defines that a code is not required and is therefore not shown in the UI
});
if (loginName) {
params.set("loginName", loginName);
}
if (organization) {
params.set("organization", organization);
}
if (authRequestId) {
params.set("authRequestId", authRequestId);
}
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{t("title")}</h1>
<p className="ztdl-p mb-6 block">{t("description")}</p>
<h1>{t("verify.title")}</h1>
<p className="ztdl-p mb-6 block">{t("verify.description")}</p>
{!userId && (
<div className="py-4">
<Alert>{tError("unknownContext")}</Alert>
</div>
<>
<h1>{t("verify.title")}</h1>
<p className="ztdl-p mb-6 block">{t("verify.description")}</p>
<div className="py-4">
<Alert>{tError("unknownContext")}</Alert>
</div>
</>
)}
{userId ? (
<VerifyEmailForm
userId={userId}
loginName={loginName}
code={code}
submit={submit === "true"}
organization={organization}
authRequestId={authRequestId}
sessionId={sessionId}
loginSettings={loginSettings}
{user && (
<UserAvatar
loginName={user.preferredLoginName}
displayName={human?.profile?.displayName}
showDropdown={false}
/>
)}
{human?.email?.isVerified ? (
<>
<Alert type={AlertType.INFO}>{t("success")}</Alert>
<div className="mt-8 flex w-full flex-row items-center">
<BackButton />
<span className="flex-grow"></span>
{authMethods?.length !== 0 && (
<Link href={`/authenticator/set?+${params}`}>
<Button
type="submit"
className="self-end"
variant={ButtonVariants.Primary}
>
{t("setupAuthenticator")}
</Button>
</Link>
)}
</div>
</>
) : (
<div className="w-full flex flex-row items-center justify-center border border-yellow-600/40 dark:border-yellow-500/20 bg-yellow-200/30 text-yellow-600 dark:bg-yellow-700/20 dark:text-yellow-200 rounded-md py-2 scroll-px-40">
<ExclamationTriangleIcon className="h-5 w-5 mr-2" />
<span className="text-center text-sm">{t("userIdMissing")}</span>
</div>
// check if auth methods are set
<VerifyForm
userId={userId}
code={code}
isInvite={invite === "true"}
params={params}
/>
)}
</div>
</DynamicTheme>

View File

@@ -104,13 +104,13 @@ export const U2F = (alreadyAdded: boolean, link: string) => {
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
strokeWidth="1.5"
stroke="currentColor"
className="w-8 h-8 mr-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
strokeLinecap="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"
/>
</svg>
@@ -139,12 +139,12 @@ export const EMAIL = (alreadyAdded: boolean, link: string) => {
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={1.5}
strokeWidth={1.5}
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
@@ -174,12 +174,12 @@ export const SMS = (alreadyAdded: boolean, link: string) => {
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
strokeWidth="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
strokeLinecap="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"
/>
</svg>
@@ -194,6 +194,68 @@ export const SMS = (alreadyAdded: boolean, link: string) => {
);
};
export const PASSKEYS = (alreadyAdded: boolean, link: string) => {
return (
<LinkWrapper alreadyAdded={alreadyAdded} link={link}>
<div
className={clsx(
"font-medium flex items-center",
alreadyAdded ? "" : "",
)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className="w-8 h-8 mr-4"
>
<path
strokeLinecap="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"
/>
</svg>
<span>Passkeys</span>
</div>
{alreadyAdded && (
<>
<Setup />
</>
)}
</LinkWrapper>
);
};
export const PASSWORD = (alreadyAdded: boolean, link: string) => {
return (
<LinkWrapper alreadyAdded={alreadyAdded} link={link}>
<div
className={clsx(
"font-medium flex items-center",
alreadyAdded ? "" : "",
)}
>
<svg
className="w-8 h-7 mr-4 fill-current"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<title>form-textbox-password</title>
<path d="M17,7H22V17H17V19A1,1 0 0,0 18,20H20V22H17.5C16.95,22 16,21.55 16,21C16,21.55 15.05,22 14.5,22H12V20H14A1,1 0 0,0 15,19V5A1,1 0 0,0 14,4H12V2H14.5C15.05,2 16,2.45 16,3C16,2.45 16.95,2 17.5,2H20V4H18A1,1 0 0,0 17,5V7M2,7H13V9H4V15H13V17H2V7M20,15V9H17V15H20M8.5,12A1.5,1.5 0 0,0 7,10.5A1.5,1.5 0 0,0 5.5,12A1.5,1.5 0 0,0 7,13.5A1.5,1.5 0 0,0 8.5,12M13,10.89C12.39,10.33 11.44,10.38 10.88,11C10.32,11.6 10.37,12.55 11,13.11C11.55,13.63 12.43,13.63 13,13.11V10.89Z" />
</svg>
<span>Password</span>
</div>
{alreadyAdded && (
<>
<Setup />
</>
)}
</LinkWrapper>
);
};
function Setup() {
return (
<div className="transform absolute right-2 top-0">

View File

@@ -12,7 +12,6 @@ import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { FieldValues, useForm } from "react-hook-form";
import { Alert } from "./alert";
@@ -57,8 +56,6 @@ export function ChangePasswordForm({
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const router = useRouter();
async function submitChange(values: Inputs) {
setLoading(true);
const changeResponse = await setMyPassword({
@@ -80,6 +77,8 @@ export function ChangePasswordForm({
return;
}
await new Promise((resolve) => setTimeout(resolve, 1000)); // wait for a second, to prevent eventual consistency issues
const passwordResponse = await sendPassword({
loginName,
organization,

View File

@@ -0,0 +1,44 @@
import {
LoginSettings,
PasskeysType,
} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { useTranslations } from "next-intl";
import { Alert, AlertType } from "./alert";
import { PASSKEYS, PASSWORD } from "./auth-methods";
type Props = {
authMethods: AuthenticationMethodType[];
params: URLSearchParams;
loginSettings: LoginSettings;
};
export function ChooseAuthenticatorToSetup({
authMethods,
params,
loginSettings,
}: Props) {
const t = useTranslations("authenticator");
if (authMethods.length !== 0) {
return <Alert type={AlertType.ALERT}>{t("allSetup")}</Alert>;
} else {
return (
<>
{loginSettings.passkeysType === PasskeysType.ALLOWED &&
!loginSettings.allowUsernamePassword && (
<Alert type={AlertType.ALERT}>{t("noMethodsAvailable")}</Alert>
)}
<div className="grid grid-cols-1 gap-5 w-full pt-4">
{!authMethods.includes(AuthenticationMethodType.PASSWORD) &&
loginSettings.allowUsernamePassword &&
PASSWORD(false, "/password/set?" + params)}
{!authMethods.includes(AuthenticationMethodType.PASSKEY) &&
loginSettings.passkeysType === PasskeysType.ALLOWED &&
PASSKEYS(false, "/passkey/set?" + params)}
</div>
</>
);
}
}

View File

@@ -14,23 +14,21 @@ export function DynamicTheme({
}) {
return (
<ThemeWrapper branding={branding}>
<div className="rounded-lg bg-vc-border-gradient dark:bg-dark-vc-border-gradient p-px shadow-lg shadow-black/5 dark:shadow-black/20 mb-10">
<div className="rounded-lg bg-background-light-400 dark:bg-background-dark-500 px-8 py-12">
<div className="mx-auto flex flex-col items-center space-y-4">
<div className="relative">
{branding && (
<Logo
lightSrc={branding.lightTheme?.logoUrl}
darkSrc={branding.darkTheme?.logoUrl}
height={150}
width={150}
/>
)}
</div>
<div className="w-full">{children}</div>
<div className="flex flex-row justify-between"></div>
<div className="rounded-lg bg-background-light-400 dark:bg-background-dark-500 px-8 py-12">
<div className="mx-auto flex flex-col items-center space-y-4">
<div className="relative">
{branding && (
<Logo
lightSrc={branding.lightTheme?.logoUrl}
darkSrc={branding.darkTheme?.logoUrl}
height={150}
width={150}
/>
)}
</div>
<div className="w-full">{children}</div>
<div className="flex flex-row justify-between"></div>
</div>
</div>
</ThemeWrapper>

View File

@@ -0,0 +1,142 @@
"use client";
import { inviteUser } from "@/lib/server/invite";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { FieldValues, useForm } from "react-hook-form";
import { Alert } from "./alert";
import { BackButton } from "./back-button";
import { Button, ButtonVariants } from "./button";
import { TextInput } from "./input";
import { Spinner } from "./spinner";
type Inputs =
| {
firstname: string;
lastname: string;
email: string;
}
| FieldValues;
type Props = {
firstname?: string;
lastname?: string;
email?: string;
organization?: string;
};
export function InviteForm({
email,
firstname,
lastname,
organization,
}: Props) {
const t = useTranslations("register");
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur",
defaultValues: {
email: email ?? "",
firstName: firstname ?? "",
lastname: lastname ?? "",
},
});
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const router = useRouter();
async function submitAndContinue(values: Inputs) {
setLoading(true);
const response = await inviteUser({
email: values.email,
firstName: values.firstname,
lastName: values.lastname,
organization: organization,
}).catch(() => {
setError("Could not create invitation Code");
setLoading(false);
return;
});
setLoading(false);
if (response && typeof response === "object" && "error" in response) {
setError(response.error);
return;
}
if (!response) {
setError("Could not create invitation Code");
return;
}
const params = new URLSearchParams({});
if (response) {
params.append("userId", response);
}
return router.push(`/invite/success?` + params);
}
const { errors } = formState;
return (
<form className="w-full">
<div className="grid grid-cols-2 gap-4 mb-4">
<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 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>
{error && (
<div className="py-4">
<Alert>{error}</Alert>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center justify-between">
<BackButton />
<Button
type="submit"
variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid}
onClick={handleSubmit(submitAndContinue)}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
{t("submit")}
</Button>
</div>
</form>
);
}

View File

@@ -51,7 +51,7 @@ export function LanguageSwitcher() {
anchor="bottom"
transition
className={clsx(
"w-[var(--button-width)] rounded-xl border border-black/5 dark:border-white/5 bg-black/5 dark:bg-white/5 p-1 [--anchor-gap:var(--spacing-1)] focus:outline-none",
"w-[var(--button-width)] rounded-xl border border-black/5 dark:border-white/5 bg-background-light-500 dark:bg-background-dark-500 p-1 [--anchor-gap:var(--spacing-1)] focus:outline-none",
"transition duration-100 ease-in data-[leave]:data-[closed]:opacity-0",
)}
>

View File

@@ -225,6 +225,7 @@ export function LoginOTP({
type="text"
{...register("code", { required: "This field is required" })}
label="Code"
autoComplete="one-time-code"
/>
</div>

View File

@@ -69,6 +69,7 @@ export function RegisterFormWithoutPassword({
}).catch((error) => {
setError("Could not register user");
setLoading(false);
return;
});
if (response && "error" in response) {
@@ -162,9 +163,7 @@ export function RegisterFormWithoutPassword({
/>
)}
<p className="mt-4 ztdl-p mb-6 block text-text-light-secondary-500 dark:text-text-dark-secondary-500">
{t("selectMethod")}
</p>
<p className="mt-4 ztdl-p mb-6 block text-left">{t("selectMethod")}</p>
<div className="pb-4">
<AuthenticationMethodRadio

View File

@@ -35,6 +35,7 @@ type Props = {
userId: string;
organization?: string;
authRequestId?: string;
codeRequired: boolean;
};
export function SetPasswordForm({
@@ -44,6 +45,7 @@ export function SetPasswordForm({
loginName,
userId,
code,
codeRequired,
}: Props) {
const t = useTranslations("password");
@@ -57,24 +59,33 @@ export function SetPasswordForm({
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
async function submitRegister(values: Inputs) {
async function submitPassword(values: Inputs) {
setLoading(true);
const changeResponse = await changePassword({
let payload: { userId: string; password: string; code?: string } = {
userId: userId,
password: values.password,
code: values.code,
}).catch(() => {
setError("Could not register user");
});
};
if (changeResponse && "error" in changeResponse) {
setError(changeResponse.error);
// this is not required for initial password setup
if (codeRequired) {
payload = { ...payload, code: values.code };
}
const changeResponse = await changePassword(payload).catch(() => {
setError("Could not set password");
setLoading(false);
return;
});
setLoading(false);
if (changeResponse && "error" in changeResponse) {
setError(changeResponse.error);
return;
}
if (!changeResponse) {
setError("Could not register user");
setError("Could not set password");
return;
}
@@ -87,6 +98,8 @@ export function SetPasswordForm({
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({
loginName,
organization,
@@ -94,7 +107,7 @@ export function SetPasswordForm({
password: { password: values.password },
}),
authRequestId,
}).catch(() => {
}).catch((error) => {
setLoading(false);
setError("Could not verify password");
return;
@@ -109,23 +122,6 @@ export function SetPasswordForm({
) {
setError(passwordResponse.error);
}
// // skip verification for now as it is an app based flow
// // return router.push(`/verify?` + params);
// // check for mfa force to continue with mfa setup
// if (authRequestId && changeResponse.sessionId) {
// if (authRequestId) {
// params.append("authRequest", authRequestId);
// }
// return router.push(`/login?` + params);
// } else {
// if (authRequestId) {
// params.append("authRequestId", authRequestId);
// }
// return router.push(`/signedin?` + params);
// }
}
const { errors } = formState;
@@ -152,24 +148,28 @@ export function SetPasswordForm({
return (
<form className="w-full">
<div className="pt-4 grid grid-cols-1 gap-4 mb-4">
<div className="flex flex-row items-end">
<div className="flex-1">
<TextInput
type="text"
required
{...register("code", {
required: "This field is required",
})}
label="Code"
error={errors.code?.message as string}
/>
{codeRequired && (
<div className="flex flex-row items-end">
<div className="flex-1">
<TextInput
type="text"
required
{...register("code", {
required: "This field is required",
})}
label="Code"
autoComplete="one-time-code"
error={errors.code?.message as string}
/>
</div>
<div className="ml-4 mb-1">
<Button variant={ButtonVariants.Secondary}>
{t("set.resend")}
</Button>
</div>
</div>
<div className="ml-4 mb-1">
<Button variant={ButtonVariants.Secondary}>
{t("set.resend")}
</Button>
</div>
</div>
)}
<div className="">
<TextInput
type="password"
@@ -217,7 +217,7 @@ export function SetPasswordForm({
!formState.isValid ||
watchPassword !== watchConfirmPassword
}
onClick={handleSubmit(submitRegister)}
onClick={handleSubmit(submitPassword)}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
{t("set.submit")}

View File

@@ -14,7 +14,7 @@ export const ThemeWrapper = ({ children, branding }: Props) => {
setTheme(document, branding);
}, []);
const defaultClasses = "bg-background-light-600 dark:bg-background-dark-600";
const defaultClasses = "";
return <div className={defaultClasses}>{children}</div>;
};

View File

@@ -42,7 +42,7 @@ export function UserAvatar({
loginName={loginName ?? ""}
/>
</div>
<span className="ml-4 text-14px max-w-[250px] text-ellipsis overflow-hidden">
<span className="ml-4 pr-4 text-14px max-w-[250px] text-ellipsis overflow-hidden">
{loginName}
</span>
<span className="flex-grow"></span>

View File

@@ -1,145 +0,0 @@
"use client";
import { Alert } from "@/components/alert";
import { resendVerifyEmail, verifyUserByEmail } from "@/lib/server/email";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { Button, ButtonVariants } from "./button";
import { TextInput } from "./input";
import { Spinner } from "./spinner";
type Inputs = {
code: string;
};
type Props = {
userId: string;
loginName: string;
code: string;
submit: boolean;
organization?: string;
authRequestId?: string;
sessionId?: string;
loginSettings?: LoginSettings;
};
export function VerifyEmailForm({
userId,
loginName,
code,
submit,
organization,
authRequestId,
sessionId,
loginSettings,
}: Props) {
const t = useTranslations("verify");
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur",
defaultValues: {
code: code ?? "",
},
});
useEffect(() => {
if (submit && 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 router = useRouter();
async function resendCode() {
setLoading(true);
const response = await resendVerifyEmail({
userId,
}).catch(() => {
setError("Could not resend email");
});
setLoading(false);
return response;
}
async function submitCodeAndContinue(value: Inputs): Promise<boolean | void> {
setLoading(true);
const verifyResponse = await verifyUserByEmail({
code: value.code,
userId,
}).catch(() => {
setError("Could not verify email");
});
setLoading(false);
if (!verifyResponse) {
setError("Could not verify email");
return;
}
const params = new URLSearchParams({});
if (organization) {
params.set("organization", organization);
}
if (authRequestId && sessionId) {
params.set("authRequest", authRequestId);
params.set("sessionId", sessionId);
return router.push(`/login?` + params);
} else {
return router.push(`/loginname?` + params);
}
}
return (
<form className="w-full">
<div className="">
<TextInput
type="text"
autoComplete="one-time-code"
{...register("code", { required: "This field is required" })}
label="Code"
// error={errors.username?.message as string}
/>
</div>
{error && (
<div className="py-4">
<Alert>{error}</Alert>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center">
<Button
type="button"
onClick={() => resendCode()}
variant={ButtonVariants.Secondary}
>
{t("resendCode")}
</Button>
<span className="flex-grow"></span>
<Button
type="submit"
className="self-end"
variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid}
onClick={handleSubmit(submitCodeAndContinue)}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
{t("submit")}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { Alert } from "@/components/alert";
import { resendVerification, sendVerification } from "@/lib/server/email";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { Button, ButtonVariants } from "./button";
import { TextInput } from "./input";
import { Spinner } from "./spinner";
type Inputs = {
code: string;
};
type Props = {
userId: string;
code?: string;
isInvite: boolean;
params: URLSearchParams;
};
export function VerifyForm({ userId, code, isInvite, params }: Props) {
const t = useTranslations("verify");
const tError = useTranslations("error");
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur",
defaultValues: {
code: code ?? "",
},
});
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const router = useRouter();
useEffect(() => {
if (code) {
submitCodeAndContinue({ code });
}
}, []);
async function resendCode() {
setLoading(true);
const response = await resendVerification({
userId,
isInvite: isInvite,
}).catch(() => {
setError("Could not resend email");
setLoading(false);
return;
});
setLoading(false);
return response;
}
async function submitCodeAndContinue(value: Inputs): Promise<boolean | void> {
setLoading(true);
const verifyResponse = await sendVerification({
code: value.code,
userId,
isInvite: isInvite,
}).catch(() => {
setError("Could not verify email");
setLoading(false);
return;
});
setLoading(false);
if (!verifyResponse) {
setError("Could not verify email");
return;
} else {
router.push("/authenticator/set?" + params);
}
}
return (
<>
<form className="w-full">
<div className="">
<TextInput
type="text"
autoComplete="one-time-code"
{...register("code", { required: "This field is required" })}
label="Code"
/>
</div>
{error && (
<div className="py-4">
<Alert>{error}</Alert>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center">
<Button
type="button"
onClick={() => resendCode()}
variant={ButtonVariants.Secondary}
>
{t("verify.resendCode")}
</Button>
<span className="flex-grow"></span>
<Button
type="submit"
className="self-end"
variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid}
onClick={handleSubmit(submitCodeAndContinue)}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
{t("verify.submit")}
</Button>
</div>
</form>
</>
);
}

View File

@@ -1,16 +1,20 @@
"use server";
import { timestampDate, timestampFromMs } from "@zitadel/client";
import { cookies } from "next/headers";
import { LANGUAGE_COOKIE_NAME } from "./i18n";
// TODO: improve this to handle overflow
const MAX_COOKIE_SIZE = 2048;
export type Cookie = {
id: string;
token: string;
loginName: string;
organization?: string;
creationDate: string;
expirationDate: string;
changeDate: string;
creationTs: string;
expirationTs: string;
changeTs: string;
authRequestId?: string; // if its linked to an OIDC flow
};
@@ -56,13 +60,24 @@ export async function addSessionToCookie<T>(
if (index > -1) {
currentSessions[index] = session;
} else {
currentSessions = [...currentSessions, session];
const temp = [...currentSessions, session];
if (JSON.stringify(temp).length >= MAX_COOKIE_SIZE) {
console.log("WARNING COOKIE OVERFLOW");
// TODO: improve cookie handling
// this replaces the first session (oldest) with the new one
currentSessions = [session].concat(currentSessions.slice(1));
} else {
currentSessions = [session].concat(currentSessions);
}
}
if (cleanup) {
const now = new Date();
const filteredSessions = currentSessions.filter((session) =>
session.expirationDate ? new Date(session.expirationDate) > now : true,
session.expirationTs
? timestampDate(timestampFromMs(Number(session.expirationTs))) > now
: true,
);
return setSessionHttpOnlyCookie(filteredSessions);
} else {
@@ -89,7 +104,9 @@ export async function updateSessionCookie<T>(
if (cleanup) {
const now = new Date();
const filteredSessions = sessions.filter((session) =>
session.expirationDate ? new Date(session.expirationDate) > now : true,
session.expirationTs
? timestampDate(timestampFromMs(Number(session.expirationTs))) > now
: true,
);
return setSessionHttpOnlyCookie(filteredSessions);
} else {
@@ -115,7 +132,9 @@ export async function removeSessionFromCookie<T>(
if (cleanup) {
const now = new Date();
const filteredSessions = reducedSessions.filter((session) =>
session.expirationDate ? new Date(session.expirationDate) > now : true,
session.expirationTs
? timestampDate(timestampFromMs(Number(session.expirationTs))) > now
: true,
);
return setSessionHttpOnlyCookie(filteredSessions);
} else {
@@ -131,10 +150,7 @@ export async function getMostRecentSessionCookie<T>(): Promise<any> {
const sessions: SessionCookie<T>[] = JSON.parse(stringifiedCookie?.value);
const latest = sessions.reduce((prev, current) => {
return new Date(prev.changeDate).getTime() >
new Date(current.changeDate).getTime()
? prev
: current;
return prev.changeTs > current.changeTs ? prev : current;
});
return latest;
@@ -216,8 +232,8 @@ export async function getAllSessionCookieIds<T>(
const now = new Date();
return sessions
.filter((session) =>
session.expirationDate
? new Date(session.expirationDate) > now
session.expirationTs
? timestampDate(timestampFromMs(Number(session.expirationTs))) > now
: true,
)
.map((session) => session.id);
@@ -246,7 +262,9 @@ export async function getAllSessions<T>(
if (cleanup) {
const now = new Date();
return sessions.filter((session) =>
session.expirationDate ? new Date(session.expirationDate) > now : true,
session.expirationTs
? timestampDate(timestampFromMs(Number(session.expirationTs))) > now
: true,
);
} else {
return sessions;
@@ -287,10 +305,7 @@ export async function getMostRecentCookieWithLoginname<T>({
const latest =
filtered && filtered.length
? filtered.reduce((prev, current) => {
return new Date(prev.changeDate).getTime() >
new Date(current.changeDate).getTime()
? prev
: current;
return prev.changeTs > current.changeTs ? prev : current;
})
: undefined;

View File

@@ -169,7 +169,6 @@ export const PROVIDER_MAPPING: {
} = {
[IdentityProviderType.GOOGLE]: (idp: IDPInformation) => {
const rawInfo = idp.rawInformation as OIDC_USER;
console.log(rawInfo);
return create(AddHumanUserRequestSchema, {
username: idp.userName,

View File

@@ -7,7 +7,7 @@ import {
getSession,
setSession,
} from "@/lib/zitadel";
import { timestampDate } from "@zitadel/client";
import { timestampMs } from "@zitadel/client";
import {
Challenges,
RequestChallenges,
@@ -20,9 +20,9 @@ type CustomCookieData = {
token: string;
loginName: string;
organization?: string;
creationDate: string;
expirationDate: string;
changeDate: string;
creationTs: string;
expirationTs: string;
changeTs: string;
authRequestId?: string; // if its linked to an OIDC flow
};
@@ -42,14 +42,14 @@ export async function createSessionAndUpdateCookie(
const sessionCookie: CustomCookieData = {
id: createdSession.sessionId,
token: createdSession.sessionToken,
creationDate: response.session.creationDate
? `${timestampDate(response.session.creationDate).toDateString()}`
creationTs: response.session.creationDate
? `${timestampMs(response.session.creationDate)}`
: "",
expirationDate: response.session.expirationDate
? `${timestampDate(response.session.expirationDate).toDateString()}`
expirationTs: response.session.expirationDate
? `${timestampMs(response.session.expirationDate)}`
: "",
changeDate: response.session.changeDate
? `${timestampDate(response.session.changeDate).toDateString()}`
changeTs: response.session.changeDate
? `${timestampMs(response.session.changeDate)}`
: "",
loginName: response.session.factors.user.loginName ?? "",
};
@@ -97,14 +97,14 @@ export async function createSessionForIdpAndUpdateCookie(
const sessionCookie: CustomCookieData = {
id: createdSession.sessionId,
token: createdSession.sessionToken,
creationDate: response.session.creationDate
? `${timestampDate(response.session.creationDate).toDateString()}`
creationTs: response.session.creationDate
? `${timestampMs(response.session.creationDate)}`
: "",
expirationDate: response.session.expirationDate
? `${timestampDate(response.session.expirationDate).toDateString()}`
expirationTs: response.session.expirationDate
? `${timestampMs(response.session.expirationDate)}`
: "",
changeDate: response.session.changeDate
? `${timestampDate(response.session.changeDate).toDateString()}`
changeTs: response.session.changeDate
? `${timestampMs(response.session.changeDate)}`
: "",
loginName: response.session.factors.user.loginName ?? "",
organization: response.session.factors.user.organizationId ?? "",
@@ -151,11 +151,11 @@ export async function setSessionAndUpdateCookie(
const sessionCookie: CustomCookieData = {
id: recentCookie.id,
token: updatedSession.sessionToken,
creationDate: recentCookie.creationDate,
expirationDate: recentCookie.expirationDate,
creationTs: recentCookie.creationTs,
expirationTs: recentCookie.expirationTs,
// just overwrite the changeDate with the new one
changeDate: updatedSession.details?.changeDate
? `${timestampDate(updatedSession.details.changeDate).toDateString()}`
changeTs: updatedSession.details?.changeDate
? `${timestampMs(updatedSession.details.changeDate)}`
: "",
loginName: recentCookie.loginName,
organization: recentCookie.organization,
@@ -174,11 +174,11 @@ export async function setSessionAndUpdateCookie(
const newCookie: CustomCookieData = {
id: sessionCookie.id,
token: updatedSession.sessionToken,
creationDate: sessionCookie.creationDate,
expirationDate: sessionCookie.expirationDate,
creationTs: sessionCookie.creationTs,
expirationTs: sessionCookie.expirationTs,
// just overwrite the changeDate with the new one
changeDate: updatedSession.details?.changeDate
? `${timestampDate(updatedSession.details.changeDate).toDateString()}`
changeTs: updatedSession.details?.changeDate
? `${timestampMs(updatedSession.details.changeDate)}`
: "",
loginName: session.factors?.user?.loginName ?? "",
organization: session.factors?.user?.organizationId ?? "",

View File

@@ -1,20 +1,96 @@
"use server";
import { resendEmailCode, verifyEmail } from "@/lib/zitadel";
import {
getUserByID,
listAuthenticationMethodTypes,
resendEmailCode,
resendInviteCode,
verifyEmail,
verifyInviteCode,
} from "@/lib/zitadel";
import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { redirect } from "next/navigation";
import { createSessionAndUpdateCookie } from "./cookie";
type VerifyUserByEmailCommand = {
userId: string;
code: string;
isInvite: boolean;
authRequestId?: string;
};
export async function verifyUserByEmail(command: VerifyUserByEmailCommand) {
return verifyEmail(command.userId, command.code);
export async function sendVerification(command: VerifyUserByEmailCommand) {
const verifyResponse = command.isInvite
? await verifyInviteCode(command.userId, command.code).catch((error) => {
return { error: "Could not verify invite" };
})
: await verifyEmail(command.userId, command.code).catch((error) => {
return { error: "Could not verify email" };
});
if (!verifyResponse) {
return { error: "Could not verify user" };
}
const userResponse = await getUserByID(command.userId);
if (!userResponse || !userResponse.user) {
return { error: "Could not load user" };
}
const checks = create(ChecksSchema, {
user: {
search: {
case: "loginName",
value: userResponse.user.preferredLoginName,
},
},
});
const session = await createSessionAndUpdateCookie(
checks,
undefined,
command.authRequestId,
);
const authMethodResponse = await listAuthenticationMethodTypes(
command.userId,
);
if (!authMethodResponse || !authMethodResponse.authMethodTypes) {
return { error: "Could not load possible authenticators" };
}
// if no authmethods are found on the user, redirect to set one up
if (
authMethodResponse &&
authMethodResponse.authMethodTypes &&
authMethodResponse.authMethodTypes.length == 0
) {
const params = new URLSearchParams({
sessionId: session.id,
});
if (session.factors?.user?.loginName) {
params.set("loginName", session.factors?.user?.loginName);
}
return redirect("/authenticator/set?" + params);
}
// return {
// authMethodTypes: authMethodResponse.authMethodTypes,
// sessionId: session.id,
// factors: session.factors,
// };
}
type resendVerifyEmailCommand = {
userId: string;
isInvite: boolean;
};
export async function resendVerifyEmail(command: resendVerifyEmailCommand) {
return resendEmailCode(command.userId);
export async function resendVerification(command: resendVerifyEmailCommand) {
return command.isInvite
? resendEmailCode(command.userId)
: resendInviteCode(command.userId);
}

View File

@@ -0,0 +1,44 @@
"use server";
import { addHumanUser, createInviteCode } from "@/lib/zitadel";
import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { headers } from "next/headers";
type InviteUserCommand = {
email: string;
firstName: string;
lastName: string;
password?: string;
organization?: string;
authRequestId?: string;
};
export type RegisterUserResponse = {
userId: string;
sessionId: string;
factors: Factors | undefined;
};
export async function inviteUser(command: InviteUserCommand) {
const host = headers().get("host");
const human = await addHumanUser({
email: command.email,
firstName: command.firstName,
lastName: command.lastName,
password: command.password ? command.password : undefined,
organization: command.organization,
});
if (!human) {
return { error: "Could not create user" };
}
const codeResponse = await createInviteCode(human.userId, host);
if (!codeResponse || !human) {
return { error: "Could not create invite code" };
}
return human.userId;
}

View File

@@ -7,6 +7,7 @@ import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_se
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { idpTypeToIdentityProviderType, idpTypeToSlug } from "../idp";
import {
getActiveIdentityProviders,
getIDPByID,
@@ -139,6 +140,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
if (users.result[0].state === UserState.INITIAL) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName,
initial: "true", // this does not require a code to be set
});
if (command.organization || session.factors?.user?.organizationId) {
@@ -160,10 +162,52 @@ export async function sendLoginname(command: SendLoginnameCommand) {
);
if (!methods.authMethodTypes || !methods.authMethodTypes.length) {
return {
error:
"User has no available authentication methods. Contact your administrator to setup authentication for the requested user.",
};
if (
users.result[0].type.case === "human" &&
users.result[0].type.value.email &&
!users.result[0].type.value.email.isVerified
) {
const paramsVerify = new URLSearchParams({
loginName: session.factors?.user?.loginName,
userId: session.factors?.user?.id, // verify needs user id
});
if (command.organization || session.factors?.user?.organizationId) {
paramsVerify.append(
"organization",
command.organization ?? session.factors?.user?.organizationId,
);
}
if (command.authRequestId) {
paramsVerify.append("authRequestId", command.authRequestId);
}
redirect("/verify?" + paramsVerify);
}
// what to do with users with valid email but no auth methods? redirect to /authenticator/set?
// return {
// error:
// "User has no available authentication methods. Contact your administrator to setup authentication for the requested user.",
// };
const paramsAuthenticatorSetup = new URLSearchParams({
loginName: session.factors?.user?.loginName,
userId: session.factors?.user?.id, // verify needs user id
});
if (command.organization || session.factors?.user?.organizationId) {
paramsAuthenticatorSetup.append(
"organization",
command.organization ?? session.factors?.user?.organizationId,
);
}
if (command.authRequestId) {
paramsAuthenticatorSetup.append("authRequestId", command.authRequestId);
}
redirect("/authenticator/set?" + paramsAuthenticatorSetup);
}
if (methods.authMethodTypes.length == 1) {

View File

@@ -71,8 +71,6 @@ export async function sendPassword(command: UpdateSessionCommand) {
organizationId: command.organization,
});
console.log(users);
if (users.details?.totalResult == BigInt(1) && users.result[0].userId) {
user = users.result[0];
@@ -89,7 +87,7 @@ export async function sendPassword(command: UpdateSessionCommand) {
}
// this is a fake error message to hide that the user does not even exist
return { error: "Could not verify password!" };
return { error: "Could not verify password" };
} else {
session = await setSessionAndUpdateCookie(
sessionCookie,
@@ -274,7 +272,7 @@ export async function sendPassword(command: UpdateSessionCommand) {
}
export async function changePassword(command: {
code: string;
code?: string;
userId: string;
password: string;
}) {
@@ -286,5 +284,5 @@ export async function changePassword(command: {
}
const userId = user.userId;
return setPassword(userId, command.password, command.code);
return setPassword(userId, command.password, user, command.code);
}

View File

@@ -4,7 +4,10 @@ import { createSessionAndUpdateCookie } from "@/lib/server/cookie";
import { addHumanUser } from "@/lib/zitadel";
import { create } from "@zitadel/client";
import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import {
ChecksJson,
ChecksSchema,
} from "@zitadel/proto/zitadel/session/v2/session_service_pb";
type RegisterUserCommand = {
email: string;
@@ -34,10 +37,18 @@ export async function registerUser(command: RegisterUserCommand) {
return { error: "Could not create user" };
}
const checks = create(ChecksSchema, {
let checkPayload: any = {
user: { search: { case: "userId", value: human.userId } },
password: { password: command.password },
});
};
if (command.password) {
checkPayload = {
...checkPayload,
password: { password: command.password },
} as ChecksJson;
}
const checks = create(ChecksSchema, checkPayload);
return createSessionAndUpdateCookie(
checks,

View File

@@ -13,6 +13,7 @@ import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { IDPInformation } from "@zitadel/proto/zitadel/user/v2/idp_pb";
import {
RetrieveIdentityProviderIntentRequest,
SetPasswordRequestSchema,
VerifyPasskeyRegistrationRequest,
VerifyU2FRegistrationRequest,
} from "@zitadel/proto/zitadel/user/v2/user_service_pb";
@@ -36,6 +37,11 @@ import {
SearchQuery,
SearchQuerySchema,
} from "@zitadel/proto/zitadel/user/v2/query_pb";
import {
SendInviteCodeSchema,
User,
UserState,
} from "@zitadel/proto/zitadel/user/v2/user_pb";
import { unstable_cache } from "next/cache";
import { PROVIDER_MAPPING } from "./idp";
@@ -280,7 +286,13 @@ export async function addHumanUser({
organization,
}: AddHumanUserData) {
return userService.addHumanUser({
email: { email },
email: {
email,
verification: {
case: "isVerified",
value: false,
},
},
username: email,
profile: { givenName: firstName, familyName: lastName },
organization: organization
@@ -300,6 +312,41 @@ export async function getUserByID(userId: string) {
return userService.getUserByID({ userId }, {});
}
export async function verifyInviteCode(
userId: string,
verificationCode: string,
) {
return userService.verifyInviteCode({ userId, verificationCode }, {});
}
export async function resendInviteCode(userId: string) {
return userService.resendInviteCode({ userId }, {});
}
export async function createInviteCode(userId: string, host: string | null) {
let medium = create(SendInviteCodeSchema, {
applicationName: "Typescript Login",
});
if (host) {
medium = {
...medium,
urlTemplate: `${host.includes("localhost") ? "http://" : "https://"}${host}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true`,
};
}
return userService.createInviteCode(
{
userId,
verification: {
case: "sendCode",
value: medium,
},
},
{},
);
}
export async function listUsers({
loginName,
userName,
@@ -370,6 +417,24 @@ export async function listUsers({
return userService.listUsers({ queries: queries });
}
export async function getDefaultOrg() {
return orgService
.listOrganizations(
{
queries: [
{
query: {
case: "defaultQuery",
value: {},
},
},
],
},
{},
)
.then((resp) => resp.result[0]);
}
export async function getOrgsByDomain(domain: string) {
return orgService.listOrganizations(
{
@@ -503,7 +568,7 @@ export async function passwordReset(userId: string, host: string | null) {
if (host) {
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}}`,
};
}
@@ -519,24 +584,57 @@ export async function passwordReset(userId: string, host: string | null) {
);
}
/**
*
* @param userId userId of the user to set the password for
* @param password the new password
* @param code optional if the password should be set with a code (reset), no code for initial setup of password
* @returns
*/
export async function setPassword(
userId: string,
password: string,
code: string,
user: User,
code?: string,
) {
return userService.setPassword(
{
userId,
newPassword: {
password,
},
let payload = create(SetPasswordRequestSchema, {
userId,
newPassword: {
password,
},
});
// check if the user has no password set in order to set a password
if (!code) {
const authmethods = await listAuthenticationMethodTypes(userId);
// if the user has no authmethods set, we can set a password otherwise we need a code
if (
!(authmethods.authMethodTypes.length === 0) &&
user.state !== UserState.INITIAL
) {
return { error: "Provide a code to set a password" };
}
}
if (code) {
payload = {
...payload,
verification: {
case: "verificationCode",
value: code,
},
},
{},
);
};
}
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

@@ -12,7 +12,7 @@
}
.ztdl-p {
@apply text-sm text-text-light-secondary-500 dark:text-text-dark-secondary-500;
@apply text-sm text-center text-text-light-secondary-500 dark:text-text-dark-secondary-500 text-center;
}
}

View File

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