From 94e2d31f5b706c854ff8eb12a015f1dd67e8f0e8 Mon Sep 17 00:00:00 2001 From: peintnermax Date: Fri, 18 Oct 2024 15:42:38 +0200 Subject: [PATCH] invite and invite success pages --- apps/login/locales/de.json | 13 ++ apps/login/locales/en.json | 13 ++ apps/login/locales/es.json | 13 ++ apps/login/locales/it.json | 13 ++ apps/login/src/app/(login)/invite/page.tsx | 54 +++++++ .../src/app/(login)/invite/success/page.tsx | 71 +++++++++ apps/login/src/app/(login)/register/page.tsx | 10 +- apps/login/src/components/invite-form.tsx | 140 ++++++++++++++++++ .../register-form-without-password.tsx | 4 +- apps/login/src/components/user-avatar.tsx | 2 +- apps/login/src/lib/server/invite.ts | 41 +++++ apps/login/src/lib/zitadel.ts | 54 +++++++ apps/login/src/styles/globals.scss | 2 +- 13 files changed, 423 insertions(+), 7 deletions(-) create mode 100644 apps/login/src/app/(login)/invite/page.tsx create mode 100644 apps/login/src/app/(login)/invite/success/page.tsx create mode 100644 apps/login/src/components/invite-form.tsx create mode 100644 apps/login/src/lib/server/invite.ts diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index 75771c1ac96..48df445d13b 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -135,6 +135,19 @@ "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.", + "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." diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 2481d047aca..8daf09faa30 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -135,6 +135,19 @@ "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.", + "submit": "Continue", + "success": { + "title": "Invite User", + "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." diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index 2643f763c9a..f0fb832c80a 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -135,6 +135,19 @@ "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.", + "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." diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index d13863ff3c0..aef1042a0d7 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -135,6 +135,19 @@ "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.", + "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." diff --git a/apps/login/src/app/(login)/invite/page.tsx b/apps/login/src/app/(login)/invite/page.tsx new file mode 100644 index 00000000000..2bf115a8d36 --- /dev/null +++ b/apps/login/src/app/(login)/invite/page.tsx @@ -0,0 +1,54 @@ +import { Alert, AlertType } from "@/components/alert"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { InviteForm } from "@/components/invite-form"; +import { + getBrandingSettings, + getDefaultOrg, + getPasswordComplexitySettings, +} from "@/lib/zitadel"; +import { getLocale, getTranslations } from "next-intl/server"; + +export default async function Page({ + searchParams, +}: { + searchParams: Record; +}) { + 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 passwordComplexitySettings = + await getPasswordComplexitySettings(organization); + + const branding = await getBrandingSettings(organization); + + return ( + +
+

{t("title")}

+

{t("description")}

+ + {t("info")} + + {passwordComplexitySettings && ( + + )} +
+
+ ); +} diff --git a/apps/login/src/app/(login)/invite/success/page.tsx b/apps/login/src/app/(login)/invite/success/page.tsx new file mode 100644 index 00000000000..2cb564580ec --- /dev/null +++ b/apps/login/src/app/(login)/invite/success/page.tsx @@ -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; +}) { + 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 ( + +
+

{t("success.title")}

+

{t("success.description")}

+ {user && ( + + )} + {human?.email?.isVerified ? ( + {t("success.verified")} + ) : ( + {t("success.notVerifiedYet")} + )} +
+ + + + +
+
+
+ ); +} diff --git a/apps/login/src/app/(login)/register/page.tsx b/apps/login/src/app/(login)/register/page.tsx index ad84e81b312..6ade9103e13 100644 --- a/apps/login/src/app/(login)/register/page.tsx +++ b/apps/login/src/app/(login)/register/page.tsx @@ -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); diff --git a/apps/login/src/components/invite-form.tsx b/apps/login/src/components/invite-form.tsx new file mode 100644 index 00000000000..d7b0aadd6ff --- /dev/null +++ b/apps/login/src/components/invite-form.tsx @@ -0,0 +1,140 @@ +"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({ + mode: "onBlur", + defaultValues: { + email: email ?? "", + firstName: firstname ?? "", + lastname: lastname ?? "", + }, + }); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + 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); + }); + + setLoading(false); + + if (response && typeof response === "object" && "error" in response) { + setError(response.error); + return; + } + + if (!response) { + setError("Could not create invitation Code"); + } + + const params = new URLSearchParams({}); + + if (response) { + params.append("userId", response); + } + + return router.push(`/invite/success?` + params); + } + + const { errors } = formState; + + return ( +
+
+
+ +
+
+ +
+
+ +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+ ); +} diff --git a/apps/login/src/components/register-form-without-password.tsx b/apps/login/src/components/register-form-without-password.tsx index df1cad2caaf..774feb64d06 100644 --- a/apps/login/src/components/register-form-without-password.tsx +++ b/apps/login/src/components/register-form-without-password.tsx @@ -162,9 +162,7 @@ export function RegisterFormWithoutPassword({ /> )} -

- {t("selectMethod")} -

+

{t("selectMethod")}

- + {loginName} diff --git a/apps/login/src/lib/server/invite.ts b/apps/login/src/lib/server/invite.ts new file mode 100644 index 00000000000..0ef1abe092b --- /dev/null +++ b/apps/login/src/lib/server/invite.ts @@ -0,0 +1,41 @@ +"use server"; + +import { addHumanUser, createInviteCode } from "@/lib/zitadel"; +import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb"; + +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 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); + + if (!codeResponse || !human) { + return { error: "Could not create invite code" }; + } + + return human.userId; +} diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index e6ff00f4dc8..9bcff825596 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -36,6 +36,7 @@ import { SearchQuery, SearchQuerySchema, } from "@zitadel/proto/zitadel/user/v2/query_pb"; +import { SendInviteCodeSchema } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { unstable_cache } from "next/cache"; import { PROVIDER_MAPPING } from "./idp"; @@ -300,6 +301,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) { + let medium = create(SendInviteCodeSchema, { + applicationName: "Typescript Login", + }); + + if (host) { + medium = { + ...medium, + urlTemplate: `https://${host}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}`, + }; + } + + return userService.createInviteCode( + { + userId, + verification: { + case: "sendCode", + value: medium, + }, + }, + {}, + ); +} + export async function listUsers({ loginName, userName, @@ -370,6 +406,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( { diff --git a/apps/login/src/styles/globals.scss b/apps/login/src/styles/globals.scss index 0cfe0fd7f29..89bb258c703 100755 --- a/apps/login/src/styles/globals.scss +++ b/apps/login/src/styles/globals.scss @@ -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; } }