invite and invite success pages

This commit is contained in:
peintnermax
2024-10-18 15:42:38 +02:00
parent 4606077ca0
commit 94e2d31f5b
13 changed files with 423 additions and 7 deletions

View File

@@ -135,6 +135,19 @@
"submit": "Weiter" "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": { "signedin": {
"title": "Willkommen {user}!", "title": "Willkommen {user}!",
"description": "Sie sind angemeldet." "description": "Sie sind angemeldet."

View File

@@ -135,6 +135,19 @@
"submit": "Continue" "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": { "signedin": {
"title": "Welcome {user}!", "title": "Welcome {user}!",
"description": "You are signed in." "description": "You are signed in."

View File

@@ -135,6 +135,19 @@
"submit": "Continuar" "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": { "signedin": {
"title": "¡Bienvenido {user}!", "title": "¡Bienvenido {user}!",
"description": "Has iniciado sesión." "description": "Has iniciado sesión."

View File

@@ -135,6 +135,19 @@
"submit": "Continua" "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": { "signedin": {
"title": "Benvenuto {user}!", "title": "Benvenuto {user}!",
"description": "Sei connesso." "description": "Sei connesso."

View 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>
);
}

View File

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

View File

@@ -3,6 +3,7 @@ import { RegisterFormWithoutPassword } from "@/components/register-form-without-
import { SetRegisterPasswordForm } from "@/components/set-register-password-form"; import { SetRegisterPasswordForm } from "@/components/set-register-password-form";
import { import {
getBrandingSettings, getBrandingSettings,
getDefaultOrg,
getLegalAndSupportSettings, getLegalAndSupportSettings,
getPasswordComplexitySettings, getPasswordComplexitySettings,
} from "@/lib/zitadel"; } from "@/lib/zitadel";
@@ -16,11 +17,16 @@ export default async function Page({
const locale = getLocale(); const locale = getLocale();
const t = await getTranslations({ locale, namespace: "register" }); const t = await getTranslations({ locale, namespace: "register" });
const { firstname, lastname, email, organization, authRequestId } = let { firstname, lastname, email, organization, authRequestId } =
searchParams; searchParams;
if (!organization) { 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); const setPassword = !!(firstname && lastname && email);

View 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>
);
}

View File

@@ -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"> <p className="mt-4 ztdl-p mb-6 block text-left">{t("selectMethod")}</p>
{t("selectMethod")}
</p>
<div className="pb-4"> <div className="pb-4">
<AuthenticationMethodRadio <AuthenticationMethodRadio

View File

@@ -42,7 +42,7 @@ export function UserAvatar({
loginName={loginName ?? ""} loginName={loginName ?? ""}
/> />
</div> </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} {loginName}
</span> </span>
<span className="flex-grow"></span> <span className="flex-grow"></span>

View 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;
}

View File

@@ -36,6 +36,7 @@ import {
SearchQuery, SearchQuery,
SearchQuerySchema, SearchQuerySchema,
} from "@zitadel/proto/zitadel/user/v2/query_pb"; } from "@zitadel/proto/zitadel/user/v2/query_pb";
import { SendInviteCodeSchema } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { unstable_cache } from "next/cache"; import { unstable_cache } from "next/cache";
import { PROVIDER_MAPPING } from "./idp"; import { PROVIDER_MAPPING } from "./idp";
@@ -300,6 +301,41 @@ export async function getUserByID(userId: string) {
return userService.getUserByID({ userId }, {}); 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({ export async function listUsers({
loginName, loginName,
userName, userName,
@@ -370,6 +406,24 @@ export async function listUsers({
return userService.listUsers({ queries: queries }); 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) { export async function getOrgsByDomain(domain: string) {
return orgService.listOrganizations( return orgService.listOrganizations(
{ {

View File

@@ -12,7 +12,7 @@
} }
.ztdl-p { .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;
} }
} }