diff --git a/apps/login/cypress/integration/verify.cy.ts b/apps/login/cypress/integration/verify.cy.ts index a26d8b51e2b..8beba527c5f 100644 --- a/apps/login/cypress/integration/verify.cy.ts +++ b/apps/login/cypress/integration/verify.cy.ts @@ -1,10 +1,11 @@ import { stub } from "../support/mock"; describe("/verify", () => { - it("redirects after successful email verification", () => { + 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.visit("/verify?userId=123&code=abc&submit=true&invite=true"); + cy.contains("Password"); + cy.contains("Passkey"); }); it("shows an error if validation failed", () => { stub("zitadel.user.v2.UserService", "VerifyEmail", { @@ -14,6 +15,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 user"); }); }); diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index 48df445d13b..5664819ef42 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -139,6 +139,7 @@ "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", @@ -153,11 +154,17 @@ "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" + "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" + }, + "setup": { + "title": "Authentifizierungsmethode auswählen", + "description": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten." + } }, "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.", diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 60c10a077fd..e5123859021 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -139,6 +139,7 @@ "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", @@ -153,11 +154,17 @@ "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" + "verify": { + "title": "Verify user", + "description": "Enter the Code provided in the verification email.", + "resendCode": "Resend code", + "submit": "Continue" + }, + "setup": { + "title": "Choose authentication method", + "description": "Select the method you would like to authenticate" + } }, "error": { "unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.", diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index f0fb832c80a..3a5f9e16438 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -139,6 +139,7 @@ "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!", @@ -153,11 +154,17 @@ "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" + "verify": { + "title": "Verificar usuario", + "description": "Introduce el código proporcionado en el correo electrónico de verificación.", + "resendCode": "Reenviar código", + "submit": "Continuar" + }, + "setup": { + "title": "Seleccionar método de autenticación", + "description": "Selecciona el método con el que deseas autenticarte" + } }, "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.", diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index aef1042a0d7..7086fc5f0f3 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -139,6 +139,7 @@ "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", @@ -153,11 +154,17 @@ "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" + "verify": { + "title": "Verifica utente", + "description": "Inserisci il codice fornito nell'email di verifica.", + "resendCode": "Invia di nuovo il codice", + "submit": "Continua" + }, + "setup": { + "title": "Seleziona metodo di autenticazione", + "description": "Seleziona il metodo con cui desideri autenticarti" + } }, "error": { "unknownContext": "Impossibile ottenere il contesto dell'utente. Assicurati di inserire prima il nome utente o di fornire un loginName come parametro di ricerca.", diff --git a/apps/login/public/logo/zitadel-logo-solo-darkdesign.svg b/apps/login/public/logo/zitadel-logo-solo-darkdesign.svg index df44ec5398d..4a4e8be71b4 100644 --- a/apps/login/public/logo/zitadel-logo-solo-darkdesign.svg +++ b/apps/login/public/logo/zitadel-logo-solo-darkdesign.svg @@ -1,6 +1,6 @@ - + diff --git a/apps/login/public/logo/zitadel-logo-solo-lightdesign.svg b/apps/login/public/logo/zitadel-logo-solo-lightdesign.svg index 4d3181174e7..33ea6b583b0 100644 --- a/apps/login/public/logo/zitadel-logo-solo-lightdesign.svg +++ b/apps/login/public/logo/zitadel-logo-solo-lightdesign.svg @@ -1,6 +1,6 @@ - + diff --git a/apps/login/public/zitadel-logo-dark.svg b/apps/login/public/zitadel-logo-dark.svg index 95ff80187c5..6dcfe06e6d3 100644 --- a/apps/login/public/zitadel-logo-dark.svg +++ b/apps/login/public/zitadel-logo-dark.svg @@ -1,6 +1,6 @@ - + diff --git a/apps/login/public/zitadel-logo-light.svg b/apps/login/public/zitadel-logo-light.svg index 7edc7489035..d48a5eeb945 100644 --- a/apps/login/public/zitadel-logo-light.svg +++ b/apps/login/public/zitadel-logo-light.svg @@ -1,6 +1,6 @@ - + diff --git a/apps/login/src/app/(login)/invite/page.tsx b/apps/login/src/app/(login)/invite/page.tsx index 2bf115a8d36..83beb9d9bf5 100644 --- a/apps/login/src/app/(login)/invite/page.tsx +++ b/apps/login/src/app/(login)/invite/page.tsx @@ -4,6 +4,7 @@ import { InviteForm } from "@/components/invite-form"; import { getBrandingSettings, getDefaultOrg, + getLoginSettings, getPasswordComplexitySettings, } from "@/lib/zitadel"; import { getLocale, getTranslations } from "next-intl/server"; @@ -27,6 +28,8 @@ export default async function Page({ organization = org.id; } + const loginSettings = await getLoginSettings(organization); + const passwordComplexitySettings = await getPasswordComplexitySettings(organization); @@ -38,9 +41,13 @@ export default async function Page({

{t("title")}

{t("description")}

- {t("info")} + {!loginSettings?.allowRegister ? ( + {t("notAllowed")} + ) : ( + {t("info")} + )} - {passwordComplexitySettings && ( + {passwordComplexitySettings && loginSettings?.allowRegister && ( { + error = "Could not verify user"; + }); + } + + 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; + } + } + } + + const params = new URLSearchParams({ + userId: userId, + initial: "true", // defines that a code is not required and is therefore not shown in the UI + }); + + if (organization) { + params.set("organization", organization); + } + + if (authRequestId) { + params.set("authRequest", authRequestId); + } + + if (sessionId) { + params.set("sessionId", sessionId); + } return (
- + {!userId && ( + <> +

{t("verify.title")}

+

{t("verify.description")}

+ +
+ {tError("unknownContext")} +
+ + )} + {!verifyResponse || !verifyResponse.authMethodTypes ? ( + + ) : ( + <> +

{t("setup.title")}

+

{t("setup.description")}

+ {user && ( + + )} + + + )}
); diff --git a/apps/login/src/components/auth-methods.tsx b/apps/login/src/components/auth-methods.tsx index 75a0cc84c16..578fdee8107 100644 --- a/apps/login/src/components/auth-methods.tsx +++ b/apps/login/src/components/auth-methods.tsx @@ -139,7 +139,7 @@ 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" > { xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" - stroke-width="1.5" + strokeWidth="1.5" stroke="currentColor" > @@ -207,13 +207,13 @@ export const PASSKEYS = (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" > diff --git a/apps/login/src/components/authenticator-methods.tsx b/apps/login/src/components/authenticator-methods.tsx new file mode 100644 index 00000000000..bd90f19b87f --- /dev/null +++ b/apps/login/src/components/authenticator-methods.tsx @@ -0,0 +1,18 @@ +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { PASSKEYS, PASSWORD } from "./auth-methods"; + +type Props = { + authMethods: AuthenticationMethodType[]; + params: URLSearchParams; +}; + +export function AuthenticatorMethods({ authMethods, params }: Props) { + return ( +
+ {!authMethods.includes(AuthenticationMethodType.PASSWORD) && + PASSWORD(false, "/password/set?" + params)} + {!authMethods.includes(AuthenticationMethodType.PASSKEY) && + PASSKEYS(false, "/passkeys/set?" + params)} +
+ ); +} diff --git a/apps/login/src/components/change-password-form.tsx b/apps/login/src/components/change-password-form.tsx index 9671752074e..5fc9368acef 100644 --- a/apps/login/src/components/change-password-form.tsx +++ b/apps/login/src/components/change-password-form.tsx @@ -77,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, diff --git a/apps/login/src/components/set-password-form.tsx b/apps/login/src/components/set-password-form.tsx index 251c04956fb..0e07767e1aa 100644 --- a/apps/login/src/components/set-password-form.tsx +++ b/apps/login/src/components/set-password-form.tsx @@ -59,7 +59,7 @@ export function SetPasswordForm({ const [loading, setLoading] = useState(false); const [error, setError] = useState(""); - async function submitRegister(values: Inputs) { + async function submitPassword(values: Inputs) { setLoading(true); let payload: { userId: string; password: string; code?: string } = { userId: userId, @@ -73,16 +73,19 @@ export function SetPasswordForm({ const changeResponse = await changePassword(payload).catch(() => { setError("Could not set password"); + setLoading(false); + return; }); - if (changeResponse && "error" in changeResponse) { - setError(changeResponse.error); - } - setLoading(false); + if (changeResponse && "error" in changeResponse) { + setError(changeResponse.error); + return; + } + if (!changeResponse) { - setError("Could not register user"); + setError("Could not set password"); return; } @@ -95,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, @@ -213,7 +218,7 @@ export function SetPasswordForm({ !formState.isValid || watchPassword !== watchConfirmPassword } - onClick={handleSubmit(submitRegister)} + onClick={handleSubmit(submitPassword)} > {loading && } {t("set.submit")} diff --git a/apps/login/src/components/verify-email-form.tsx b/apps/login/src/components/verify-form.tsx similarity index 72% rename from apps/login/src/components/verify-email-form.tsx rename to apps/login/src/components/verify-form.tsx index 31540b445be..4dfca56d799 100644 --- a/apps/login/src/components/verify-email-form.tsx +++ b/apps/login/src/components/verify-form.tsx @@ -2,13 +2,12 @@ import { Alert } from "@/components/alert"; import { resendVerification, verifyUser } from "@/lib/server/email"; -import { LoginSettings } 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 { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useForm } from "react-hook-form"; -import { PASSKEYS, PASSWORD } from "./auth-methods"; +import { AuthenticatorMethods } from "./authenticator-methods"; import { Button, ButtonVariants } from "./button"; import { TextInput } from "./input"; import { Spinner } from "./spinner"; @@ -18,25 +17,25 @@ type Inputs = { }; type Props = { - userId?: string; + userId: string; loginName: string; code: string; organization?: string; authRequestId?: string; sessionId?: string; - loginSettings?: LoginSettings; isInvite: boolean; + verifyError?: string; }; -export function VerifyEmailForm({ +export function VerifyForm({ userId, loginName, code, organization, authRequestId, sessionId, - loginSettings, isInvite, + verifyError, }: Props) { const t = useTranslations("verify"); const tError = useTranslations("error"); @@ -52,30 +51,19 @@ export function VerifyEmailForm({ AuthenticationMethodType[] | null >(null); - useEffect(() => { - if (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(""); + const [error, setError] = useState(verifyError || ""); const [loading, setLoading] = useState(false); const router = useRouter(); - const params = new URLSearchParams({}); - - if (userId) { - params.append("userId", userId); - } + const params = new URLSearchParams({ + userId: userId, + }); if (isInvite) { params.append("initial", "true"); } - if (loginName) { params.append("loginName", loginName); } @@ -131,13 +119,10 @@ export function VerifyEmailForm({ // if auth methods fall trough, we complete to login const params = new URLSearchParams({ + userId: userId, initial: "true", // defines that a code is not required and is therefore not shown in the UI }); - if (userId) { - params.set("userId", userId); - } - if (organization) { params.set("organization", organization); } @@ -153,14 +138,8 @@ export function VerifyEmailForm({ return !authMethods ? ( <> -

{t("title")}

-

{t("description")}

- - {!userId && ( -
- {tError("unknownContext")} -
- )} +

{t("verify.title")}

+

{t("verify.description")}

@@ -169,7 +148,6 @@ export function VerifyEmailForm({ autoComplete="one-time-code" {...register("code", { required: "This field is required" })} label="Code" - // error={errors.username?.message as string} />
@@ -185,7 +163,7 @@ export function VerifyEmailForm({ onClick={() => resendCode()} variant={ButtonVariants.Secondary} > - {t("resendCode")} + {t("verify.resendCode")}
) : ( <> -

{t("title")}

-

{t("description")}

+

{t("setup.title")}

+

{t("setup.description")}

-
- {!authMethods.includes(AuthenticationMethodType.PASSWORD) && - PASSWORD(false, "/password/set?" + params)} - {!authMethods.includes(AuthenticationMethodType.PASSKEY) && - PASSKEYS(false, "/passkeys/set?" + params)} -
+ ); } diff --git a/apps/login/src/lib/server/cookie.ts b/apps/login/src/lib/server/cookie.ts index 65dcc1494aa..2b380bf3253 100644 --- a/apps/login/src/lib/server/cookie.ts +++ b/apps/login/src/lib/server/cookie.ts @@ -7,7 +7,7 @@ import { getSession, setSession, } from "@/lib/zitadel"; -import { timestampDate } from "@zitadel/client"; +import { timestampMs } from "@zitadel/client"; import { Challenges, RequestChallenges, @@ -43,13 +43,13 @@ export async function createSessionAndUpdateCookie( id: createdSession.sessionId, token: createdSession.sessionToken, creationDate: response.session.creationDate - ? `${timestampDate(response.session.creationDate).toDateString()}` + ? `${timestampMs(response.session.creationDate)}` : "", expirationDate: response.session.expirationDate - ? `${timestampDate(response.session.expirationDate).toDateString()}` + ? `${timestampMs(response.session.expirationDate)}` : "", changeDate: response.session.changeDate - ? `${timestampDate(response.session.changeDate).toDateString()}` + ? `${timestampMs(response.session.changeDate)}` : "", loginName: response.session.factors.user.loginName ?? "", }; @@ -98,13 +98,13 @@ export async function createSessionForIdpAndUpdateCookie( id: createdSession.sessionId, token: createdSession.sessionToken, creationDate: response.session.creationDate - ? `${timestampDate(response.session.creationDate).toDateString()}` + ? `${timestampMs(response.session.creationDate)}` : "", expirationDate: response.session.expirationDate - ? `${timestampDate(response.session.expirationDate).toDateString()}` + ? `${timestampMs(response.session.expirationDate)}` : "", changeDate: response.session.changeDate - ? `${timestampDate(response.session.changeDate).toDateString()}` + ? `${timestampMs(response.session.changeDate)}` : "", loginName: response.session.factors.user.loginName ?? "", organization: response.session.factors.user.organizationId ?? "", @@ -155,7 +155,7 @@ export async function setSessionAndUpdateCookie( expirationDate: recentCookie.expirationDate, // just overwrite the changeDate with the new one changeDate: updatedSession.details?.changeDate - ? `${timestampDate(updatedSession.details.changeDate).toDateString()}` + ? `${timestampMs(updatedSession.details.changeDate)}` : "", loginName: recentCookie.loginName, organization: recentCookie.organization, @@ -178,7 +178,7 @@ export async function setSessionAndUpdateCookie( expirationDate: sessionCookie.expirationDate, // just overwrite the changeDate with the new one changeDate: updatedSession.details?.changeDate - ? `${timestampDate(updatedSession.details.changeDate).toDateString()}` + ? `${timestampMs(updatedSession.details.changeDate)}` : "", loginName: session.factors?.user?.loginName ?? "", organization: session.factors?.user?.organizationId ?? "", diff --git a/apps/login/src/lib/server/loginname.ts b/apps/login/src/lib/server/loginname.ts index 2d67b46f3a0..2a115b8cd4c 100644 --- a/apps/login/src/lib/server/loginname.ts +++ b/apps/login/src/lib/server/loginname.ts @@ -139,6 +139,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) { diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index 4997f23ca08..2cb512bb971 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -284,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); } diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index 0f9a1534a2d..864d19ae25f 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -37,7 +37,11 @@ import { SearchQuery, SearchQuerySchema, } from "@zitadel/proto/zitadel/user/v2/query_pb"; -import { SendInviteCodeSchema } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { + SendInviteCodeSchema, + User, + UserState, +} from "@zitadel/proto/zitadel/user/v2/user_pb"; import { unstable_cache } from "next/cache"; import { PROVIDER_MAPPING } from "./idp"; @@ -327,7 +331,7 @@ export async function createInviteCode(userId: string, host: string | null) { if (host) { medium = { ...medium, - urlTemplate: `https://${host}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true`, + urlTemplate: `${host.includes("localhost") ? "http://" : "https://"}${host}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true`, }; } @@ -564,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}}`, }; } @@ -590,6 +594,7 @@ export async function passwordReset(userId: string, host: string | null) { export async function setPassword( userId: string, password: string, + user: User, code?: string, ) { let payload = create(SetPasswordRequestSchema, { @@ -605,9 +610,8 @@ export async function setPassword( // if the user has no authmethods set, we can set a password otherwise we need a code if ( - !authmethods || - !authmethods.authMethodTypes || - authmethods.authMethodTypes.length === 0 + !(authmethods.authMethodTypes.length === 0) && + user.state !== UserState.INITIAL ) { return { error: "Provide a code to set a password" }; } @@ -623,7 +627,14 @@ export async function setPassword( }; } - return userService.setPassword(payload, {}); + 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; + } + }); } /** diff --git a/packages/zitadel-client/src/index.ts b/packages/zitadel-client/src/index.ts index 2e262dd013f..d78559257d2 100644 --- a/packages/zitadel-client/src/index.ts +++ b/packages/zitadel-client/src/index.ts @@ -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, timestampMs } from "@bufbuild/protobuf/wkt"; export type { Timestamp } from "@bufbuild/protobuf/wkt";