Merge pull request #233 from zitadel/invite-users
feat: user invite links
3
apps/login/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
custom-config.js
|
||||
custom-config.js
|
||||
.env.local
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
135
apps/login/src/app/(login)/authenticator/set/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
apps/login/src/app/(login)/invite/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
apps/login/src/app/(login)/invite/success/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
44
apps/login/src/components/choose-authenticator-to-setup.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
142
apps/login/src/components/invite-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -225,6 +225,7 @@ export function LoginOTP({
|
||||
type="text"
|
||||
{...register("code", { required: "This field is required" })}
|
||||
label="Code"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
127
apps/login/src/components/verify-form.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
44
apps/login/src/lib/server/invite.ts
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||