mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-12 21:53:08 +00:00
invite and invite success pages
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
54
apps/login/src/app/(login)/invite/page.tsx
Normal file
54
apps/login/src/app/(login)/invite/page.tsx
Normal file
@@ -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<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 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>
|
||||
|
||||
<Alert type={AlertType.INFO}>{t("info")}</Alert>
|
||||
|
||||
{passwordComplexitySettings && (
|
||||
<InviteForm
|
||||
organization={organization}
|
||||
firstname={firstname}
|
||||
lastname={lastname}
|
||||
email={email}
|
||||
></InviteForm>
|
||||
)}
|
||||
</div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
}
|
||||
71
apps/login/src/app/(login)/invite/success/page.tsx
Normal file
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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
140
apps/login/src/components/invite-form.tsx
Normal file
140
apps/login/src/components/invite-form.tsx
Normal file
@@ -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<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);
|
||||
});
|
||||
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -162,9 +162,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
41
apps/login/src/lib/server/invite.ts
Normal file
41
apps/login/src/lib/server/invite.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user